]> quire.kejadlen.dev Git - quire.git/commitdiff
Extract Quire struct to replace Repo and Config threading
authorAlpha Chen <alpha@kejadlen.dev>
Sat, 25 Apr 2026 14:10:21 +0000 (07:10 -0700)
committerAlpha Chen <alpha@kejadlen.dev>
Sat, 25 Apr 2026 14:14:19 +0000 (14:14 +0000)
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
src/bin/quire/commands/exec.rs
src/bin/quire/commands/repo.rs
src/bin/quire/commands/serve.rs
src/bin/quire/main.rs
src/lib.rs
src/quire.rs [new file with mode: 0644]
src/repo.rs [deleted file]

index 8af4a0148229075f8b426c7efaf71b5e570fc02c..f66d2f393ec3da4c999a5805275079fd91b90012 100644 (file)
@@ -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<String>) -> Result<()> {
+pub async fn run(quire: &Quire, command: Vec<String>) -> 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<String>) -> 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} '<repo>', 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]);
index 0fde07af940afe03fbb56f018a7e64e215257b5d..bff0b279504f2bf7206780b65a1c9147f90da562 100644 (file)
@@ -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<String> = 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(())
 }
index 3166282fc2cb99bf1c6feb764d38c312d028e908..5c56671f41aafc3826f860de37068bdac565bc95 100644 (file)
@@ -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()
index c49821b509a28bbffa97131e9d4cee1a61efaa16..26d7f193a240dc0722641dc8cd3339c84ec0c2b6 100644 (file)
@@ -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?,
         },
     }
 
index bc0f5cf7d6b6987ce330cfa109a9bb6de5a90934..91ccc749cb8b0e895b0826b015529d5af99a992d 100644 (file)
@@ -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 (file)
index 0000000..66fef54
--- /dev/null
@@ -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<PathBuf> {
+        validate_repo_name(name)?;
+        Ok(self.config.repos_dir.join(name))
+    }
+
+    /// List all repository names under the repos directory.
+    pub fn repos(&self) -> Result<impl Iterator<Item = String> + '_> {
+        let entries = fs_err::read_dir(&self.config.repos_dir).into_diagnostic()?;
+
+        let mut repos: Vec<String> = 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::<Vec<_>>();
+    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 (file)
index b53353d..0000000
+++ /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<Self> {
-        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<String> {
-    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::<Vec<_>>();
-    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());
-    }
-}