Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/target
.claude/
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — |
Expand Down
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/cli/add.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use std::path::PathBuf;

pub fn run(_file: PathBuf, _profile: Option<String>) -> crate::error::Result<()> {
use crate::ui::UiContext;

pub fn run(_file: PathBuf, _profile: Option<String>, _ctx: &UiContext) -> crate::error::Result<()> {
Ok(())
}
4 changes: 3 additions & 1 deletion src/cli/apply.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub fn run(_profiles: Vec<String>) -> crate::error::Result<()> {
use crate::ui::UiContext;

pub fn run(_profiles: Vec<String>, _ctx: &UiContext) -> crate::error::Result<()> {
Ok(())
}
4 changes: 3 additions & 1 deletion src/cli/doctor.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub fn run() -> crate::error::Result<()> {
use crate::ui::UiContext;

pub fn run(_ctx: &UiContext) -> crate::error::Result<()> {
Ok(())
}
8 changes: 7 additions & 1 deletion src/cli/init.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
pub fn run(_url: Option<String>, _profiles: Vec<String>) -> crate::error::Result<()> {
use crate::ui::UiContext;

pub fn run(
_url: Option<String>,
_profiles: Vec<String>,
_ctx: &UiContext,
) -> crate::error::Result<()> {
Ok(())
}
4 changes: 3 additions & 1 deletion src/cli/status.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub fn run() -> crate::error::Result<()> {
use crate::ui::UiContext;

pub fn run(_ctx: &UiContext) -> crate::error::Result<()> {
Ok(())
}
4 changes: 3 additions & 1 deletion src/cli/sync.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub fn run() -> crate::error::Result<()> {
use crate::ui::UiContext;

pub fn run(_ctx: &UiContext) -> crate::error::Result<()> {
Ok(())
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod cli;
pub mod config;
pub mod error;
pub mod ui;
19 changes: 12 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
126 changes: 126 additions & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<bool> {
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<T: Display>(&self, msg: &str, options: Vec<T>, default: usize) -> Result<usize> {
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<T: Display>(&self, msg: &str, options: Vec<T>) -> Result<Vec<usize>> {
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<ConflictAction> {
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<Spinner> {
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();
}
}
}
88 changes: 88 additions & 0 deletions src/ui/prompt.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<bool> {
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<T: Display>(msg: &str, options: Vec<T>, default: usize) -> Result<usize> {
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<T: Display>(msg: &str, options: Vec<T>) -> Result<Vec<usize>> {
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<ConflictAction> {
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"),
})
}
Loading
Loading