diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b074e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + ci: + name: Lint & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + # Fmt needs no compilation — fails fast before cache/compile steps + - name: Check formatting + run: cargo fmt --check + + - uses: Swatinem/rust-cache@v2 + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Test + run: cargo test diff --git a/.gitignore b/.gitignore index ea8c4bf..d58c33b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md index 7a9f502..8b6ccd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,3 +49,11 @@ Rust edition: **2024** - Symlinks: check if dest already points to correct src before touching it - Hooks stream stdout/stderr live with `│ ` prefix — don't capture, pipe it - Integration tests use `tempfile::tempdir()` — never touch the real home directory + +## Agent Routing +- After implementing any new Rust function, module, or struct → delegate to the `documentation-expert` agent +- When asked to "add docs" or "document" anything → always use the `documentation-expert` agent +- Never write documentation inline in the main session +- After implementing any new Rust change, delegate to `rust-pro` agent +- On every cli ui plan check with `cli-ui-designer` agent +- On every cli ui change delegate to `cli-ui-designer` agent diff --git a/README.md b/README.md index d7b2914..e7de18e 100644 --- a/README.md +++ b/README.md @@ -235,13 +235,13 @@ cargo fmt --check # check formatting ## Project Status -anvil is in early development, working through [Phase 1](ROADMAP.md#phase-1--interactive-mvp) of the roadmap. +anvil is in early development, working through **Phase 1** of the roadmap. | Step | Status | |------|--------| | 1. Foundation (error types, config structs) | Done | | 2. CLI Skeleton (clap dispatch, stub commands) | Done | -| 3. UI Module (UiContext, prompts, spinners) | — | +| 3. UI Module (UiContext, prompts, spinners) | Done | | 4. Git Backend (GitBackend trait, ShellGit) | — | | 5. Linker (symlinks, copy fallback) | — | | 6–9. Commands & polish | — | diff --git a/ROADMAP.md b/ROADMAP.md index f7b5fc7..70729ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,7 +42,7 @@ Steps 2–5 can proceed in parallel after Step 1. Step 6 unifies them into the f - Depends on: Step 1 - ~200 lines -- [ ] **Step 3: UI Module** — Theme, spinner, prompt wrappers, and the full `UiContext` that controls `--yes`, `--quiet`, `--dry-run` behavior. +- [x] **Step 3: UI Module** — Theme, spinner, prompt wrappers, and the full `UiContext` that controls `--yes`, `--quiet`, `--dry-run` behavior. - Delivers: `src/ui/mod.rs` (UiContext), `src/ui/theme.rs`, `src/ui/spinner.rs`, `src/ui/prompt.rs`, `src/ui/summary.rs` - Depends on: Step 1 - ~300 lines @@ -98,7 +98,8 @@ Production hardening and public release. - [ ] `doctor` command - [ ] `--dry-run` support on all write commands -- [ ] GitHub Actions: CI + cross-platform release binaries (Linux x86_64, macOS x86_64/aarch64) +- [x] GitHub Actions: CI (fmt, clippy, tests on PRs) +- [ ] GitHub Actions: cross-platform release binaries (Linux x86_64, macOS x86_64/aarch64) - [ ] `libgit2` backend (no git binary required) - [ ] Shell completions via clap (bash, zsh, fish) - [ ] Publish to crates.io diff --git a/src/cli/add.rs b/src/cli/add.rs index 814fc9a..3bc9945 100644 --- a/src/cli/add.rs +++ b/src/cli/add.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; -pub fn run(_file: PathBuf, _profile: Option) -> crate::error::Result<()> { +use crate::ui::UiContext; + +pub fn run(_file: PathBuf, _profile: Option, _ctx: &UiContext) -> crate::error::Result<()> { Ok(()) } diff --git a/src/cli/apply.rs b/src/cli/apply.rs index 1b0c3fd..a7bc409 100644 --- a/src/cli/apply.rs +++ b/src/cli/apply.rs @@ -1,3 +1,5 @@ -pub fn run(_profiles: Vec) -> crate::error::Result<()> { +use crate::ui::UiContext; + +pub fn run(_profiles: Vec, _ctx: &UiContext) -> crate::error::Result<()> { Ok(()) } diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 7a1e7b1..ad3ce20 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -1,3 +1,5 @@ -pub fn run() -> crate::error::Result<()> { +use crate::ui::UiContext; + +pub fn run(_ctx: &UiContext) -> crate::error::Result<()> { Ok(()) } diff --git a/src/cli/init.rs b/src/cli/init.rs index 142c3fb..25b39a3 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,3 +1,9 @@ -pub fn run(_url: Option, _profiles: Vec) -> crate::error::Result<()> { +use crate::ui::UiContext; + +pub fn run( + _url: Option, + _profiles: Vec, + _ctx: &UiContext, +) -> crate::error::Result<()> { Ok(()) } diff --git a/src/cli/status.rs b/src/cli/status.rs index 7a1e7b1..ad3ce20 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -1,3 +1,5 @@ -pub fn run() -> crate::error::Result<()> { +use crate::ui::UiContext; + +pub fn run(_ctx: &UiContext) -> crate::error::Result<()> { Ok(()) } diff --git a/src/cli/sync.rs b/src/cli/sync.rs index 7a1e7b1..ad3ce20 100644 --- a/src/cli/sync.rs +++ b/src/cli/sync.rs @@ -1,3 +1,5 @@ -pub fn run() -> crate::error::Result<()> { +use crate::ui::UiContext; + +pub fn run(_ctx: &UiContext) -> crate::error::Result<()> { Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 4d1f950..4c804ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ pub mod cli; pub mod config; pub mod error; +pub mod ui; diff --git a/src/main.rs b/src/main.rs index 30e76ec..160efb4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,26 @@ use clap::Parser; +use console::Term; use anvil::cli::{self, Cli, Command}; +use anvil::ui::UiContext; fn main() { let cli = Cli::parse(); + let quiet = cli.quiet || !Term::stdout().is_term(); + let ctx = UiContext::new(cli.yes, quiet, cli.dry_run); + let result = match cli.command.unwrap_or(Command::Status) { - Command::Init { url, profile } => cli::init::run(url, profile), - Command::Sync => cli::sync::run(), - Command::Apply { profile } => cli::apply::run(profile), - Command::Add { file, profile } => cli::add::run(file, profile), - Command::Status => cli::status::run(), - Command::Doctor => cli::doctor::run(), + Command::Init { url, profile } => cli::init::run(url, profile, &ctx), + Command::Sync => cli::sync::run(&ctx), + Command::Apply { profile } => cli::apply::run(profile, &ctx), + Command::Add { file, profile } => cli::add::run(file, profile, &ctx), + Command::Status => cli::status::run(&ctx), + Command::Doctor => cli::doctor::run(&ctx), }; if let Err(e) = result { - eprintln!("error: {e}"); + ctx.error(&e.to_string()); std::process::exit(1); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..20d5dfa --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,126 @@ +//! Terminal I/O layer for anvil. +//! +//! All user-facing output and interactive prompts flow through this module. +//! Commands receive a [`UiContext`] by reference and call its methods instead +//! of using `println!` or `inquire` directly. This keeps flag handling +//! (`--yes`, `--quiet`, `--dry-run`) in one place. + +pub mod prompt; +pub mod spinner; +pub mod summary; +pub mod theme; + +use std::fmt::Display; +use std::path::Path; + +use crate::error::{AnvilError, Result}; + +use self::prompt::ConflictAction; +use self::spinner::Spinner; + +/// Central UI controller passed to every command by reference. +/// Encapsulates `--yes`, `--quiet`, and `--dry-run` flags so commands +/// do not sprinkle conditionals. +pub struct UiContext { + pub yes: bool, + pub quiet: bool, + pub dry_run: bool, +} + +impl UiContext { + /// Creates a new `UiContext` from the global CLI flags. + pub fn new(yes: bool, quiet: bool, dry_run: bool) -> Self { + Self { + yes, + quiet, + dry_run, + } + } + + // -- Prompt methods (short-circuit on --yes) -- + + /// Prompts for free-form text. Under `--yes`, returns `default` or + /// errors with `PromptCancelled` if no default is provided. + pub fn text(&self, msg: &str, default: Option<&str>) -> Result { + if self.yes { + return default + .map(|d| d.to_string()) + .ok_or(AnvilError::PromptCancelled); + } + prompt::text(msg, default) + } + + /// Prompts for yes/no confirmation. Under `--yes`, returns `default`. + pub fn confirm(&self, msg: &str, default: bool) -> Result { + if self.yes { + return Ok(default); + } + prompt::confirm(msg, default) + } + + /// Prompts the user to pick one option. Returns the chosen index. + /// Under `--yes`, returns `default` (validated against `options.len()`). + pub fn select(&self, msg: &str, options: Vec, default: usize) -> Result { + if self.yes { + return if default < options.len() { + Ok(default) + } else { + Err(AnvilError::Other(format!( + "select default index {default} out of range (len {})", + options.len() + ))) + }; + } + prompt::select(msg, options, default) + } + + /// Prompts for multiple selections. Under `--yes`, selects all options. + pub fn multi_select(&self, msg: &str, options: Vec) -> Result> { + if self.yes { + return Ok((0..options.len()).collect()); + } + prompt::multi_select(msg, options) + } + + /// Asks how to handle a conflicting file. Under `--yes`, returns `Overwrite`. + pub fn conflict_resolution(&self, path: &Path) -> Result { + if self.yes { + return Ok(ConflictAction::Overwrite); + } + prompt::conflict_resolution(path) + } + + // -- Output methods (respect --quiet) -- + + /// Starts an animated spinner. Returns `None` when `--quiet` is active. + pub fn spinner(&self, msg: &str) -> Option { + if self.quiet { + return None; + } + Some(spinner::start(msg)) + } + + /// Prints a green success message. Suppressed by `--quiet`. + pub fn success(&self, msg: &str) { + if !self.quiet { + theme::print_success(msg); + } + } + + /// Prints a yellow warning to stderr. Always shown regardless of `--quiet`. + pub fn warn(&self, msg: &str) { + theme::print_warn(msg); + } + + /// Prints a red error to stderr. Always shown regardless of `--quiet`. + pub fn error(&self, msg: &str) { + theme::print_error(msg); + } + + /// Prints the anvil ASCII banner. Suppressed by `--quiet`. + pub fn header(&self) { + if !self.quiet { + theme::print_header(); + } + } +} diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs new file mode 100644 index 0000000..d2b2b7b --- /dev/null +++ b/src/ui/prompt.rs @@ -0,0 +1,88 @@ +//! Interactive prompt wrappers built on [`inquire`]. +//! +//! These functions are the low-level building blocks called by +//! [`super::UiContext`]. They should not be used directly by command +//! code -- go through `UiContext` so that `--yes` short-circuiting +//! is handled automatically. + +use std::fmt::Display; +use std::path::Path; + +use inquire::{Confirm, InquireError, MultiSelect, Select, Text}; + +use crate::error::{AnvilError, Result}; + +/// Maps `InquireError` to `AnvilError`, treating user cancellation and +/// interrupt (Ctrl-C) as `PromptCancelled`. +fn map_inquire_err(e: InquireError) -> AnvilError { + match e { + InquireError::OperationCanceled | InquireError::OperationInterrupted => { + AnvilError::PromptCancelled + } + other => AnvilError::Other(other.to_string()), + } +} + +/// Prompts for free-form text input with an optional default value. +pub fn text(msg: &str, default: Option<&str>) -> Result { + let mut prompt = Text::new(msg); + if let Some(d) = default { + prompt = prompt.with_default(d); + } + prompt.prompt().map_err(map_inquire_err) +} + +/// Prompts for a yes/no confirmation. +pub fn confirm(msg: &str, default: bool) -> Result { + Confirm::new(msg) + .with_default(default) + .prompt() + .map_err(map_inquire_err) +} + +/// Prompts the user to pick one option. Returns the chosen index. +pub fn select(msg: &str, options: Vec, default: usize) -> Result { + Select::new(msg, options) + .with_starting_cursor(default) + .raw_prompt() + .map(|opt| opt.index) + .map_err(map_inquire_err) +} + +/// Prompts the user to pick multiple options. Returns the chosen indices. +pub fn multi_select(msg: &str, options: Vec) -> Result> { + MultiSelect::new(msg, options) + .raw_prompt() + .map(|opts| opts.into_iter().map(|o| o.index).collect()) + .map_err(map_inquire_err) +} + +/// Action the user can take when a target file already exists. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictAction { + /// Replace the existing file with the new symlink/copy. + Overwrite, + /// Leave the existing file untouched. + Skip, + /// Display a diff between the existing file and the source. + ShowDiff, +} + +/// Asks the user how to handle a conflicting file at `path`. +pub fn conflict_resolution(path: &Path) -> Result { + let options = vec!["Overwrite", "Skip", "Show diff"]; + let idx = Select::new( + &format!("{} already exists. What to do?", path.display()), + options, + ) + .raw_prompt() + .map(|opt| opt.index) + .map_err(map_inquire_err)?; + + Ok(match idx { + 0 => ConflictAction::Overwrite, + 1 => ConflictAction::Skip, + 2 => ConflictAction::ShowDiff, + _ => unreachable!("select returned index outside option bounds"), + }) +} diff --git a/src/ui/spinner.rs b/src/ui/spinner.rs new file mode 100644 index 0000000..922acea --- /dev/null +++ b/src/ui/spinner.rs @@ -0,0 +1,59 @@ +//! Animated terminal spinner for long-running operations. +//! +//! Wraps [`indicatif::ProgressBar`] in a thin [`Spinner`] type that +//! exposes only three terminal states: [`Spinner::success`], [`Spinner::warn`], +//! and [`Spinner::fail`]. Create one via [`start`] (or, preferably, +//! through [`super::UiContext::spinner`] which respects `--quiet`). + +use std::time::Duration; + +use console::style; +use indicatif::{ProgressBar, ProgressStyle}; + +use super::theme::{SYMBOL_ERR, SYMBOL_OK, SYMBOL_WARN}; + +/// A running terminal spinner. Consuming methods stop the animation and +/// print a final status line. +pub struct Spinner(ProgressBar); + +/// Creates and immediately starts a braille-dot spinner with the given message. +pub fn start(msg: &str) -> Spinner { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) + .template(" {spinner:.cyan} {msg}") + .expect("hardcoded spinner template is valid"), + ); + pb.set_message(msg.to_string()); + pb.enable_steady_tick(Duration::from_millis(80)); + Spinner(pb) +} + +impl Spinner { + /// Stops the spinner and prints a green checkmark with the given message. + pub fn success(self, msg: &str) { + self.0 + .finish_with_message(format!("{} {msg}", style(SYMBOL_OK).green().bold())); + } + + /// Stops the spinner and prints a yellow warning symbol with the given message. + pub fn warn(self, msg: &str) { + self.0 + .finish_with_message(format!("{} {msg}", style(SYMBOL_WARN).yellow().bold())); + } + + /// Stops the spinner and prints a red cross with the given message. + pub fn fail(self, msg: &str) { + self.0 + .finish_with_message(format!("{} {msg}", style(SYMBOL_ERR).red().bold())); + } +} + +impl Drop for Spinner { + fn drop(&mut self) { + if !self.0.is_finished() { + self.0.finish_and_clear(); + } + } +} diff --git a/src/ui/summary.rs b/src/ui/summary.rs new file mode 100644 index 0000000..4fc2e67 --- /dev/null +++ b/src/ui/summary.rs @@ -0,0 +1,70 @@ +//! Post-apply summary reporting. +//! +//! [`ApplySummary`] accumulates per-file outcomes during `anvil apply` and +//! prints a coloured one-line summary when the operation finishes. + +use console::style; + +use super::UiContext; +use super::theme::{INDENT, SYMBOL_ERR, SYMBOL_OK, SYMBOL_WARN}; + +/// Tracks the outcome counts for a batch apply operation. +/// +/// Increment `linked`, `skipped`, and `failed` as each file is processed, +/// then call [`print`](ApplySummary::print) to render the final summary. +#[derive(Debug, Default)] +pub struct ApplySummary { + /// Number of files successfully linked. + pub linked: usize, + /// Number of files skipped (already correct or user chose to skip). + pub skipped: usize, + /// Number of files that failed to link. + pub failed: usize, +} + +impl ApplySummary { + /// Creates a new summary with all counters at zero. + pub fn new() -> Self { + Self::default() + } + + /// Prints the summary line. Respects `--quiet` via the provided context. + /// The leading symbol reflects the worst outcome: red cross if any failed, + /// yellow warning if any skipped, green checkmark otherwise. + pub fn print(&self, ctx: &UiContext) { + if ctx.quiet { + return; + } + + let mut parts = Vec::new(); + + if self.linked > 0 { + parts.push(format!("{} linked", style(self.linked).green().bold())); + } + if self.skipped > 0 { + parts.push(format!("{} skipped", style(self.skipped).yellow().bold())); + } + if self.failed > 0 { + parts.push(format!("{} failed", style(self.failed).red().bold())); + } + + let joined = parts.join(", "); + + if self.failed > 0 { + println!( + "\n{INDENT}{} Failed. {joined}", + style(SYMBOL_ERR).red().bold() + ); + } else if self.skipped > 0 { + println!( + "\n{INDENT}{} Done! {joined}", + style(SYMBOL_WARN).yellow().bold() + ); + } else { + println!( + "\n{INDENT}{} Done! {joined}", + style(SYMBOL_OK).green().bold() + ); + } + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..ada6a6f --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,47 @@ +//! Visual constants and print helpers for consistent terminal output. +//! +//! All themed output (symbols, colours, the ASCII banner) is defined here +//! so the rest of the codebase stays free of formatting details. + +use console::style; + +/// Indentation prefix used across all output lines. +pub const INDENT: &str = " "; + +/// Success checkmark. +pub const SYMBOL_OK: &str = "\u{2713}"; +/// Failure cross. +pub const SYMBOL_ERR: &str = "\u{2717}"; +/// Warning triangle. +pub const SYMBOL_WARN: &str = "\u{26a0}"; +/// Directional arrow for linking output. +pub const SYMBOL_ARROW: &str = "\u{2192}"; + +/// Prints a success message to stdout with green checkmark. +pub fn print_success(msg: &str) { + println!("{INDENT}{} {msg}", style(SYMBOL_OK).green().bold()); +} + +/// Prints an error message to stderr with red cross. +pub fn print_error(msg: &str) { + eprintln!("{INDENT}{} {msg}", style(SYMBOL_ERR).red().bold()); +} + +/// Prints a warning message to stderr with yellow triangle. +pub fn print_warn(msg: &str) { + eprintln!("{INDENT}{} {msg}", style(SYMBOL_WARN).yellow().bold()); +} + +/// Prints the anvil ASCII banner with version info. +pub fn print_header() { + let version = env!("CARGO_PKG_VERSION"); + println!(); + println!("{INDENT}{}", style("▗▄▖ █▄ █ █ █ ██▄ █").bold()); + println!(" {}", style("▐▌ ▐▌█ ▀█ ▀▄▀ █▄█ █▄▄").bold()); + println!( + " {} {}", + style("dotfiles manager").dim(), + style(format!("v{version}")).dim(), + ); + println!(); +}