diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..77b686f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI +on: + pull_request: + branches: [master] +concurrency: + group: ci-${{ github.head_ref || github.ref }} + cancel-in-progress: true +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 +jobs: + check: + name: fmt + clippy + test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all -- --check + - run: cargo clippy --all-targets --locked -- -D warnings + - run: cargo test --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..72708b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Release +on: + push: + tags: ["v*"] +permissions: + contents: write # required to create the release + upload assets +env: + CARGO_TERM_COLOR: always +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, suffix: linux-amd64 } + - { os: macos-latest, target: aarch64-apple-darwin, suffix: macos-arm64 } + - { os: macos-latest, target: x86_64-apple-darwin, suffix: macos-amd64 } + - { os: windows-latest, target: x86_64-pc-windows-msvc, suffix: windows-amd64, ext: ".exe" } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + - run: cargo build --release --locked --target ${{ matrix.target }} + - name: Stage artifact + shell: bash + run: | + v="${GITHUB_REF_NAME#v}" + src="target/${{ matrix.target }}/release/mail-cli${{ matrix.ext }}" + dst="mail-cli-${v}-${{ matrix.suffix }}${{ matrix.ext }}" + mkdir -p dist && cp "$src" "dist/$dst" + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.suffix }} + path: dist/* + publish: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify tag matches Cargo.toml version + run: | + v="${GITHUB_REF_NAME#v}" + cargo_v=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) + [ "$v" = "$cargo_v" ] || { echo "tag $v != Cargo.toml $cargo_v"; exit 1; } + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true diff --git a/src/main.rs b/src/main.rs index d572422..3de46a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -290,9 +290,7 @@ fn run() -> Result<()> { html, raw, } => cmd_read(cli.json, &folder, selector, html, raw), - Command::Attachments { folder, selector } => { - cmd_attachments(cli.json, &folder, selector) - } + Command::Attachments { folder, selector } => cmd_attachments(cli.json, &folder, selector), Command::Download { folder, selector, @@ -329,7 +327,10 @@ fn resolve( let n = idx as usize; let uids = imap::newest_uids(session, folder, n, None)?; let uid = uids.get(n - 1).copied().ok_or_else(|| { - anyhow!("no message #{idx} in {folder} (only {} present)", uids.len()) + anyhow!( + "no message #{idx} in {folder} (only {} present)", + uids.len() + ) })?; Ok((uid, Some(n))) } @@ -523,7 +524,10 @@ mod tests { #[test] fn selector_parses_index() { - assert!(matches!("3".parse::().unwrap(), Selector::Index(3))); + assert!(matches!( + "3".parse::().unwrap(), + Selector::Index(3) + )); } #[test] diff --git a/src/output.rs b/src/output.rs index 153396f..68d6d48 100644 --- a/src/output.rs +++ b/src/output.rs @@ -89,7 +89,10 @@ pub fn attachments_human(uid: u32, index: Option, atts: &[Attachment]) { println!("(no attachments)"); return; } - println!("{:>2} {:<30} {:<28} {:>10}", "K", "FILENAME", "MIME", "SIZE"); + println!( + "{:>2} {:<30} {:<28} {:>10}", + "K", "FILENAME", "MIME", "SIZE" + ); for a in atts { println!( "{:>2} {:<30} {:<28} {:>10}", diff --git a/src/parse.rs b/src/parse.rs index 126c301..83e8d02 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -55,7 +55,9 @@ fn parse(raw: &[u8]) -> Result> { /// Join address-list headers into a display string like `Name , c@d`. fn addrs(list: Option<&mail_parser::Address<'_>>) -> String { - let Some(addr) = list else { return String::new() }; + let Some(addr) = list else { + return String::new(); + }; let mut out = Vec::new(); for a in addr.iter() { let email = a.address().unwrap_or_default(); @@ -108,10 +110,7 @@ fn collect_attachments(msg: &Message<'_>) -> Vec { let bytes = part.contents().to_vec(); Attachment { index: i + 1, - filename: part - .attachment_name() - .unwrap_or("unnamed") - .to_string(), + filename: part.attachment_name().unwrap_or("unnamed").to_string(), mime: part .content_type() .map(|ct| match ct.subtype() { diff --git a/src/search.rs b/src/search.rs index 2d5dab3..7945bb3 100644 --- a/src/search.rs +++ b/src/search.rs @@ -61,10 +61,16 @@ impl Criteria { // Dates (converted to IMAP's dd-Mon-yyyy at this boundary). if let Some(d) = &self.since { - terms.push(format!("SINCE {}", imap_date(d).map_err(rename("--since"))?)); + terms.push(format!( + "SINCE {}", + imap_date(d).map_err(rename("--since"))? + )); } if let Some(d) = &self.before { - terms.push(format!("BEFORE {}", imap_date(d).map_err(rename("--before"))?)); + terms.push(format!( + "BEFORE {}", + imap_date(d).map_err(rename("--before"))? + )); } if let Some(d) = &self.on { terms.push(format!("ON {}", imap_date(d).map_err(rename("--on"))?)); @@ -142,7 +148,10 @@ fn imap_date(iso: &str) -> Result { if !(1..=days_in_month(year, month)).contains(&day) { bail!("day out of range in {iso:?}"); } - Ok(format!("{day:02}-{}-{year:04}", MONTHS[(month - 1) as usize])) + Ok(format!( + "{day:02}-{}-{year:04}", + MONTHS[(month - 1) as usize] + )) } /// Days in a given month, honoring Gregorian leap years. @@ -217,10 +226,7 @@ mod tests { body: Some(r#"say "hi"\bye"#.into()), ..Default::default() }; - assert_eq!( - c.to_imap_query().unwrap(), - r#"BODY "say \"hi\"\\bye""# - ); + assert_eq!(c.to_imap_query().unwrap(), r#"BODY "say \"hi\"\\bye""#); } #[test] diff --git a/src/skill.rs b/src/skill.rs index 44ccbfa..10c8e18 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -23,7 +23,10 @@ const ASSETS: &[(&str, &str)] = &[ "references/reference.md", include_str!("../skill/references/reference.md"), ), - ("evals/evals.json", include_str!("../skill/evals/evals.json")), + ( + "evals/evals.json", + include_str!("../skill/evals/evals.json"), + ), ]; /// The default install root: `~/.agents/skills/mail-cli`. @@ -33,10 +36,7 @@ pub fn default_dir() -> Result { let home = std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .context("cannot locate home directory: neither HOME nor USERPROFILE is set")?; - Ok(Path::new(&home) - .join(".agents") - .join("skills") - .join(NAME)) + Ok(Path::new(&home).join(".agents").join("skills").join(NAME)) } /// Write the bundled skill into `dir`, creating parent directories as needed. @@ -52,7 +52,10 @@ pub fn install(dir: &Path, force: bool) -> Result> { dest.push(part); } if dest.exists() && !force { - bail!("{} already exists; pass --force to overwrite", dest.display()); + bail!( + "{} already exists; pass --force to overwrite", + dest.display() + ); } if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent) diff --git a/tests/search_integration.rs b/tests/search_integration.rs index 4a2c63b..e6580ca 100644 --- a/tests/search_integration.rs +++ b/tests/search_integration.rs @@ -70,9 +70,8 @@ fn seed( internal_date: DateTime, flags: &[Flag<'static>], ) { - let raw = format!( - "From: {from}\r\nTo: rcpt@example.com\r\nSubject: {subject}\r\n\r\n{body}\r\n" - ); + let raw = + format!("From: {from}\r\nTo: rcpt@example.com\r\nSubject: {subject}\r\n\r\n{body}\r\n"); let mut cmd = session.append("INBOX", raw.as_bytes()); cmd.internal_date(internal_date); for f in flags { @@ -124,9 +123,23 @@ fn from_and_subject_and_flags() { let mut s = session_for(&user); let d = dt("2026-03-10T12:00:00+00:00"); - seed(&mut s, "alice@example.com", "Invoice #7", "body one", d, &[Flag::Seen]); + seed( + &mut s, + "alice@example.com", + "Invoice #7", + "body one", + d, + &[Flag::Seen], + ); seed(&mut s, "bob@example.com", "Lunch plans", "body two", d, &[]); - seed(&mut s, "alice@example.com", "Weekly report", "body three", d, &[]); + seed( + &mut s, + "alice@example.com", + "Weekly report", + "body three", + d, + &[], + ); let alice_seen = uid_of(&mut s, "Invoice #7"); let bob_unseen = uid_of(&mut s, "Lunch plans"); let alice_unseen = uid_of(&mut s, "Weekly report"); @@ -244,7 +257,10 @@ fn size_bounds() { }; let got = search_uids(&mut s, &larger); assert!(got.contains(&big), "big message should match LARGER 2000"); - assert!(!got.contains(&small), "small message should NOT match LARGER 2000"); + assert!( + !got.contains(&small), + "small message should NOT match LARGER 2000" + ); let _ = s.logout(); }