From: Alpha Chen Date: Sat, 25 Apr 2026 14:10:21 +0000 (-0700) Subject: Extract Quire struct to replace Repo and Config threading X-Git-Url: http://quire.kejadlen.dev/?a=commitdiff_plain;h=1058300b003062e6a8e0245815c9a85b52acc2d6;p=quire.git Extract Quire struct to replace Repo and Config threading Repo was a thin validated-name wrapper that required callers to pass repos_dir alongside it at every call site. Quire holds the config and provides repo() -> PathBuf and repos() -> Iterator, collapsing two arguments into one at every command handler. Assisted-by: GLM-5.1 via pi --- diff --git a/src/bin/quire/commands/exec.rs b/src/bin/quire/commands/exec.rs index 8af4a01..f66d2f3 100644 --- a/src/bin/quire/commands/exec.rs +++ b/src/bin/quire/commands/exec.rs @@ -3,12 +3,11 @@ use std::process::Command; use miette::{Context, IntoDiagnostic, Result, bail, ensure}; -use quire::Config; -use quire::repo::Repo; +use quire::Quire; const GIT_COMMANDS: &[&str] = &["git-receive-pack", "git-upload-pack", "git-upload-archive"]; -pub async fn run(config: &Config, command: Vec) -> Result<()> { +pub async fn run(quire: &Quire, command: Vec) -> Result<()> { let input = if command.len() == 1 { // Single argument: the full SSH_ORIGINAL_COMMAND string. // e.g. git-receive-pack '/foo.git' @@ -27,15 +26,15 @@ pub async fn run(config: &Config, command: Vec) -> Result<()> { let cmd = &words[0]; if GIT_COMMANDS.contains(&cmd.as_str()) { - dispatch_git(config, cmd, &words[1..]) + dispatch_git(quire, cmd, &words[1..]) } else if cmd == "quire" { - dispatch_quire(config, &words[1..]) + dispatch_quire(&words[1..]) } else { bail!("unsupported command: {cmd}") } } -fn dispatch_git(config: &Config, git_cmd: &str, args: &[String]) -> Result<()> { +fn dispatch_git(quire: &Quire, git_cmd: &str, args: &[String]) -> Result<()> { ensure!( args.len() == 1, "expected usage: {git_cmd} '', got {} arguments", @@ -45,22 +44,16 @@ fn dispatch_git(config: &Config, git_cmd: &str, args: &[String]) -> Result<()> { let path = args[0].trim_start_matches('/'); ensure!(!path.is_empty(), "empty repository path"); - let repo = Repo::from_name(path)?; - ensure!( - repo.exists(&config.repos_dir), - "repository not found: {}", - repo.name() - ); - - let repo_dir = repo.path(&config.repos_dir); + let repo_dir = quire.repo(path)?; + ensure!(repo_dir.is_dir(), "repository not found: {path}"); - tracing::info!(%git_cmd, name = %repo.name(), "dispatching git command"); + tracing::info!(%git_cmd, %path, "dispatching git command"); let err = Command::new(git_cmd).arg(".").current_dir(&repo_dir).exec(); bail!("exec failed: {err}") } -fn dispatch_quire(_config: &Config, args: &[String]) -> Result<()> { +fn dispatch_quire(args: &[String]) -> Result<()> { ensure!(!args.is_empty(), "no quire subcommand provided"); ensure!(args[0] == "repo", "unsupported quire command: {}", args[0]); diff --git a/src/bin/quire/commands/repo.rs b/src/bin/quire/commands/repo.rs index 0fde07a..bff0b27 100644 --- a/src/bin/quire/commands/repo.rs +++ b/src/bin/quire/commands/repo.rs @@ -2,18 +2,11 @@ use std::process::Command; use miette::{IntoDiagnostic, Result, ensure}; -use quire::Config; -use quire::repo::Repo; +use quire::Quire; -pub async fn new(config: &Config, name: &str) -> Result<()> { - let repo = Repo::from_name(name)?; - ensure!( - !repo.exists(&config.repos_dir), - "repository already exists: {}", - repo.name() - ); - - let repo_dir = repo.path(&config.repos_dir); +pub async fn new(quire: &Quire, name: &str) -> Result<()> { + let repo_dir = quire.repo(name)?; + ensure!(!repo_dir.is_dir(), "repository already exists: {name}"); // Create parent directory for grouped repos (e.g. work/foo.git). if let Some(parent) = repo_dir.parent() { @@ -21,85 +14,40 @@ pub async fn new(config: &Config, name: &str) -> Result<()> { } let status = Command::new("git") - .args(["init", "--bare", repo.name()]) - .current_dir(&config.repos_dir) + .args(["init", "--bare", name]) + .current_dir(quire.repos_dir()) .status() .into_diagnostic()?; ensure!(status.success(), "git init failed"); - tracing::info!(name = %repo.name(), "created repository"); - println!("{}", repo.name()); + tracing::info!(%name, "created repository"); + println!("{name}"); Ok(()) } -pub async fn list(config: &Config) -> Result<()> { - let entries = fs_err::read_dir(&config.repos_dir).into_diagnostic()?; - - let mut repos: Vec = Vec::new(); - for entry in entries { - let entry = entry.into_diagnostic()?; - let path = entry.path(); - - if !path.is_dir() { - continue; - } - - let Ok(relative) = path.strip_prefix(&config.repos_dir) else { - continue; - }; - let name = relative.to_string_lossy(); - - // Top-level .git directory. - if name.ends_with(".git") { - repos.push(name.to_string()); - continue; - } - - // Group directory — collect .git children. - let Ok(children) = fs_err::read_dir(&path) else { - continue; - }; - for child in children { - let child = child.into_diagnostic()?; - let child_name = child.file_name(); - let child_name = child_name.to_string_lossy(); - if child_name.ends_with(".git") && child.path().is_dir() { - let full = format!("{}/{}", name, child_name); - repos.push(full); - } - } - } - - repos.sort(); - for repo in &repos { - println!("{repo}"); +pub async fn list(quire: &Quire) -> Result<()> { + for name in quire.repos()? { + println!("{name}"); } - Ok(()) } -pub async fn rm(config: &Config, name: &str) -> Result<()> { - let repo = Repo::from_name(name)?; - ensure!( - repo.exists(&config.repos_dir), - "repository not found: {}", - repo.name() - ); - - let repo_dir = repo.path(&config.repos_dir); +pub async fn rm(quire: &Quire, name: &str) -> Result<()> { + let repo_dir = quire.repo(name)?; + ensure!(repo_dir.is_dir(), "repository not found: {name}"); fs_err::remove_dir_all(&repo_dir).into_diagnostic()?; // Clean up empty parent directory for grouped repos. if let Some(parent) = repo_dir.parent() - && parent != config.repos_dir + && parent != quire.repos_dir() { let _ = fs_err::remove_dir(parent); } - tracing::info!(name = %repo.name(), "removed repository"); + tracing::info!(%name, "removed repository"); Ok(()) } diff --git a/src/bin/quire/commands/serve.rs b/src/bin/quire/commands/serve.rs index 3166282..5c56671 100644 --- a/src/bin/quire/commands/serve.rs +++ b/src/bin/quire/commands/serve.rs @@ -5,7 +5,7 @@ use axum::routing::get; use miette::IntoDiagnostic; use miette::Result; -use quire::Config; +use quire::Quire; async fn health() -> &'static str { "ok" @@ -15,7 +15,7 @@ async fn index() -> &'static str { "quire\n" } -pub async fn run(_config: &Config) -> Result<()> { +pub async fn run(_quire: &Quire) -> Result<()> { let addr: SocketAddr = ([0, 0, 0, 0], 3000).into(); let app = Router::new() diff --git a/src/bin/quire/main.rs b/src/bin/quire/main.rs index c49821b..26d7f19 100644 --- a/src/bin/quire/main.rs +++ b/src/bin/quire/main.rs @@ -5,6 +5,7 @@ use clap_complete::Shell; use miette::IntoDiagnostic; use miette::Result; use quire::Config; +use quire::Quire; use tracing_subscriber::EnvFilter; use tracing_subscriber::fmt; use tracing_subscriber::prelude::*; @@ -88,16 +89,16 @@ async fn main() -> Result<()> { return Ok(()); }; - let config = Config::default(); + let quire = Quire::new(Config::default()); match command { - Commands::Serve => commands::serve::run(&config).await?, - Commands::Exec { command } => commands::exec::run(&config, command).await?, + Commands::Serve => commands::serve::run(&quire).await?, + Commands::Exec { command } => commands::exec::run(&quire, command).await?, Commands::Hook { hook_name } => commands::hook::run(hook_name).await?, Commands::Repo { command } => match command { - RepoCommands::New { name } => commands::repo::new(&config, &name).await?, - RepoCommands::List => commands::repo::list(&config).await?, - RepoCommands::Rm { name } => commands::repo::rm(&config, &name).await?, + RepoCommands::New { name } => commands::repo::new(&quire, &name).await?, + RepoCommands::List => commands::repo::list(&quire).await?, + RepoCommands::Rm { name } => commands::repo::rm(&quire, &name).await?, }, } diff --git a/src/lib.rs b/src/lib.rs index bc0f5cf..91ccc74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,8 @@ mod config; mod error; -pub mod repo; +pub mod quire; pub use config::Config; pub use error::Error; pub use error::Result; +pub use quire::Quire; diff --git a/src/quire.rs b/src/quire.rs new file mode 100644 index 0000000..66fef54 --- /dev/null +++ b/src/quire.rs @@ -0,0 +1,169 @@ +use std::path::{Path, PathBuf}; + +use miette::{IntoDiagnostic, Result, ensure}; + +use crate::config::Config; + +/// Application runtime context. +/// +/// Carries configuration and provides resolved paths to repositories. +/// Commands receive a `&Quire` instead of threading `&Config` around. +pub struct Quire { + config: Config, +} + +impl Quire { + pub fn new(config: Config) -> Self { + Self { config } + } + + pub fn repos_dir(&self) -> &Path { + &self.config.repos_dir + } + + /// Validate a repository name and return its resolved path. + /// + /// Rejects path traversal, missing `.git` suffix, empty segments, + /// reserved path components, and more than one level of grouping. + pub fn repo(&self, name: &str) -> Result { + validate_repo_name(name)?; + Ok(self.config.repos_dir.join(name)) + } + + /// List all repository names under the repos directory. + pub fn repos(&self) -> Result + '_> { + let entries = fs_err::read_dir(&self.config.repos_dir).into_diagnostic()?; + + let mut repos: Vec = Vec::new(); + for entry in entries { + let entry = entry.into_diagnostic()?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let Ok(relative) = path.strip_prefix(&self.config.repos_dir) else { + continue; + }; + let name = relative.to_string_lossy(); + + // Top-level .git directory. + if name.ends_with(".git") { + repos.push(name.to_string()); + continue; + } + + // Group directory — collect .git children. + let Ok(children) = fs_err::read_dir(&path) else { + continue; + }; + for child in children { + let child = child.into_diagnostic()?; + let child_name = child.file_name(); + let child_name = child_name.to_string_lossy(); + if child_name.ends_with(".git") && child.path().is_dir() { + let full = format!("{}/{}", name, child_name); + repos.push(full); + } + } + } + + repos.sort(); + Ok(repos.into_iter()) + } +} + +/// Validate a repository name. +/// +/// Allows at most one level of grouping (e.g. `foo.git` or `work/foo.git`). +/// Rejects path traversal, missing `.git` suffix, empty segments, and +/// reserved path components. +fn validate_repo_name(name: &str) -> Result<()> { + ensure!(!name.is_empty(), "repository name cannot be empty"); + ensure!(!name.contains(".."), "invalid repository name: {name}"); + ensure!( + name.ends_with(".git"), + "repository name must end in .git: {name}" + ); + ensure!(!name.contains("//"), "invalid repository name: {name}"); + + let segments = name.split('/').collect::>(); + ensure!( + segments.len() <= 2, + "repository name allows at most one level of grouping: {name}" + ); + + for seg in &segments { + ensure!(!seg.is_empty(), "invalid repository name: {name}"); + ensure!( + *seg != "." && *seg != ".." && *seg != ".git", + "invalid repository name: {name}" + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn quire() -> Quire { + Quire::new(Config::default()) + } + + #[test] + fn repo_valid() { + let q = quire(); + assert!(q.repo("foo.git").is_ok()); + assert!(q.repo("work/foo.git").is_ok()); + } + + #[test] + fn repo_resolves_path() { + let q = quire(); + assert_eq!( + q.repo("foo.git").unwrap(), + PathBuf::from("/var/quire/repos/foo.git") + ); + } + + #[test] + fn rejects_empty() { + let q = quire(); + assert!(q.repo("").is_err()); + } + + #[test] + fn rejects_traversal() { + let q = quire(); + assert!(q.repo("../foo.git").is_err()); + assert!(q.repo("foo/../../bar.git").is_err()); + assert!(q.repo("./foo.git").is_err()); + } + + #[test] + fn rejects_no_git_suffix() { + let q = quire(); + assert!(q.repo("foo").is_err()); + } + + #[test] + fn rejects_deep_nesting() { + let q = quire(); + assert!(q.repo("a/b/c.git").is_err()); + } + + #[test] + fn rejects_double_slash() { + let q = quire(); + assert!(q.repo("foo//bar.git").is_err()); + } + + #[test] + fn rejects_dot_git_segment() { + let q = quire(); + assert!(q.repo("foo/.git").is_err()); + } +} diff --git a/src/repo.rs b/src/repo.rs deleted file mode 100644 index b53353d..0000000 --- a/src/repo.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::path::{Path, PathBuf}; - -use miette::{Result, ensure}; - -/// A validated repository name relative to the repos directory. -#[derive(Debug, Clone)] -pub struct Repo { - name: String, -} - -impl Repo { - /// Parse a repository name (e.g. `foo.git`, `work/foo.git`). - /// - /// Rejects path traversal, missing `.git` suffix, empty segments, - /// reserved path components, and more than one level of grouping. - pub fn from_name(name: &str) -> Result { - validate_segments(name)?; - Ok(Repo { - name: name.to_string(), - }) - } - - pub fn name(&self) -> &str { - &self.name - } - - pub fn path(&self, repos_dir: &Path) -> PathBuf { - repos_dir.join(&self.name) - } - - pub fn exists(&self, repos_dir: &Path) -> bool { - self.path(repos_dir).is_dir() - } -} - -/// Validate segments of a repository name. -/// -/// Allows at most one level of grouping (e.g. `foo.git` or `work/foo.git`). -/// Rejects path traversal, missing `.git` suffix, empty segments, and -/// reserved path components. -fn validate_segments(name: &str) -> Result { - ensure!(!name.is_empty(), "repository name cannot be empty"); - ensure!(!name.contains(".."), "invalid repository name: {name}"); - ensure!( - name.ends_with(".git"), - "repository name must end in .git: {name}" - ); - ensure!(!name.contains("//"), "invalid repository name: {name}"); - - let segments = name.split('/').collect::>(); - ensure!( - segments.len() <= 2, - "repository name allows at most one level of grouping: {name}" - ); - - for seg in &segments { - ensure!(!seg.is_empty(), "invalid repository name: {name}"); - ensure!( - *seg != "." && *seg != ".." && *seg != ".git", - "invalid repository name: {name}" - ); - } - - Ok(name.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn from_name_valid() { - assert_eq!(Repo::from_name("foo.git").unwrap().name(), "foo.git"); - assert_eq!( - Repo::from_name("work/foo.git").unwrap().name(), - "work/foo.git" - ); - } - - #[test] - fn rejects_empty() { - assert!(Repo::from_name("").is_err()); - } - - #[test] - fn rejects_traversal() { - assert!(Repo::from_name("../foo.git").is_err()); - assert!(Repo::from_name("foo/../../bar.git").is_err()); - assert!(Repo::from_name("./foo.git").is_err()); - } - - #[test] - fn rejects_no_git_suffix() { - assert!(Repo::from_name("foo").is_err()); - } - - #[test] - fn rejects_deep_nesting() { - assert!(Repo::from_name("a/b/c.git").is_err()); - } - - #[test] - fn rejects_double_slash() { - assert!(Repo::from_name("foo//bar.git").is_err()); - } - - #[test] - fn rejects_dot_git_segment() { - assert!(Repo::from_name("foo/.git").is_err()); - } -}