feat: project scanner CLI in Rust

scans git repos, reports status/staleness, writes state files
to ~/.agents/projects/ for cross-agent continuity.

usage: projscan [--json|--update]
This commit is contained in:
Nicholai Vogel 2026-01-24 04:20:44 -07:00
commit 56919cbfab
4 changed files with 1441 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
target/

1077
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "projscan"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
colored = "2"
git2 = "0.19"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
[profile.release]
strip = true
lto = true

344
src/main.rs Normal file
View File

@ -0,0 +1,344 @@
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use clap::Parser;
use colored::Colorize;
use git2::{Repository, StatusOptions};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "projscan", about = "Scan project directories and report git status")]
struct Cli {
/// Output as JSON
#[arg(long)]
json: bool,
/// Write state files to ~/.agents/projects/
#[arg(long)]
update: bool,
}
#[derive(Deserialize)]
struct Config {
project: Vec<ProjectConfig>,
}
#[derive(Deserialize, Clone)]
struct ProjectConfig {
name: String,
path: String,
priority: String,
}
#[derive(Serialize, Clone)]
struct ProjectState {
name: String,
path: String,
priority: String,
status: String,
branch: String,
uncommitted: usize,
untracked: usize,
last_commit: String,
last_message: String,
staleness_days: i64,
ahead: Option<usize>,
behind: Option<usize>,
error: Option<String>,
}
fn config_path() -> PathBuf {
dirs_or_default("config").join("projscan/config.toml")
}
fn agents_projects_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".agents/projects")
}
fn dirs_or_default(kind: &str) -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
match kind {
"config" => PathBuf::from(&home).join(".config"),
_ => PathBuf::from(&home),
}
}
fn load_config() -> Result<Config> {
let path = config_path();
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config: {}", path.display()))?;
let config: Config = toml::from_str(&content)
.with_context(|| "Failed to parse config")?;
Ok(config)
}
fn scan_project(project: &ProjectConfig) -> ProjectState {
match scan_project_inner(project) {
Ok(state) => state,
Err(e) => ProjectState {
name: project.name.clone(),
path: project.path.clone(),
priority: project.priority.clone(),
status: "error".into(),
branch: String::new(),
uncommitted: 0,
untracked: 0,
last_commit: String::new(),
last_message: String::new(),
staleness_days: 0,
ahead: None,
behind: None,
error: Some(e.to_string()),
},
}
}
fn scan_project_inner(project: &ProjectConfig) -> Result<ProjectState> {
let repo = Repository::open(&project.path)
.with_context(|| format!("Not a git repo: {}", project.path))?;
// Current branch
let branch = match repo.head() {
Ok(head) => head.shorthand().unwrap_or("HEAD").to_string(),
Err(_) => "HEAD".into(),
};
// Status counts
let mut opts = StatusOptions::new();
opts.include_untracked(true);
let statuses = repo.statuses(Some(&mut opts))?;
let mut uncommitted = 0;
let mut untracked = 0;
for entry in statuses.iter() {
let s = entry.status();
if s.contains(git2::Status::WT_NEW) {
untracked += 1;
} else if s.intersects(
git2::Status::INDEX_NEW
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_DELETED
| git2::Status::INDEX_RENAMED
| git2::Status::WT_MODIFIED
| git2::Status::WT_DELETED
| git2::Status::WT_RENAMED,
) {
uncommitted += 1;
}
}
// Last commit
let head_commit = repo.head()?.peel_to_commit()?;
let commit_time = head_commit.time();
let timestamp = commit_time.seconds();
let commit_dt = DateTime::from_timestamp(timestamp, 0)
.unwrap_or_default()
.naive_utc();
let last_commit = commit_dt.format("%Y-%m-%d").to_string();
let last_message = head_commit
.summary()
.unwrap_or("")
.to_string();
// Staleness
let now = Utc::now().naive_utc();
let staleness_days = (now - commit_dt).num_days();
// Ahead/behind remote
let (ahead, behind) = get_ahead_behind(&repo, &branch);
// Status logic
let status = if uncommitted > 0 || staleness_days <= 3 {
"active".into()
} else if staleness_days > 7 {
"stale".into()
} else {
"clean".into()
};
Ok(ProjectState {
name: project.name.clone(),
path: project.path.clone(),
priority: project.priority.clone(),
status,
branch,
uncommitted,
untracked,
last_commit,
last_message,
staleness_days,
ahead: ahead.map(|v| v),
behind: behind.map(|v| v),
error: None,
})
}
fn get_ahead_behind(
repo: &Repository,
branch: &str,
) -> (Option<usize>, Option<usize>) {
let local = match repo.revparse_single(&format!("refs/heads/{}", branch)) {
Ok(obj) => obj.id(),
Err(_) => return (None, None),
};
let upstream_ref = format!("refs/remotes/origin/{}", branch);
let remote = match repo.revparse_single(&upstream_ref) {
Ok(obj) => obj.id(),
Err(_) => return (None, None),
};
match repo.graph_ahead_behind(local, remote) {
Ok((ahead, behind)) => (Some(ahead), Some(behind)),
Err(_) => (None, None),
}
}
fn print_pretty(states: &[ProjectState]) {
println!("{}", "".repeat(60).dimmed());
println!("{}", " PROJECT SCAN RESULTS".bold());
println!("{}", "".repeat(60).dimmed());
for state in states {
let status_colored = match state.status.as_str() {
"active" => state.status.green().bold(),
"stale" => state.status.red().bold(),
"clean" => state.status.cyan().bold(),
"error" => state.status.yellow().bold(),
_ => state.status.normal(),
};
let priority_colored = match state.priority.as_str() {
"high" => state.priority.red(),
"medium" => state.priority.yellow(),
"low" => state.priority.dimmed(),
_ => state.priority.normal(),
};
println!();
println!(
" {} [{}] ({})",
state.name.bold(),
status_colored,
priority_colored
);
if let Some(ref err) = state.error {
println!(" {} {}", "error:".red(), err);
continue;
}
println!(
" {} {}",
"branch:".dimmed(),
state.branch
);
if state.uncommitted > 0 || state.untracked > 0 {
let changes = format!(
"{} uncommitted, {} untracked",
state.uncommitted, state.untracked
);
println!(
" {} {}",
"changes:".dimmed(),
changes.yellow()
);
}
println!(
" {} {} ({} days ago)",
"commit:".dimmed(),
state.last_commit,
state.staleness_days
);
println!(
" {} {}",
"message:".dimmed(),
state.last_message.dimmed()
);
if let (Some(a), Some(b)) = (state.ahead, state.behind) {
if a > 0 || b > 0 {
println!(
" {} ↑{} ↓{}",
"remote:".dimmed(),
a,
b
);
}
}
}
println!();
println!("{}", "".repeat(60).dimmed());
}
fn write_state_files(states: &[ProjectState]) -> Result<()> {
let dir = agents_projects_dir();
fs::create_dir_all(&dir)?;
for state in states {
if state.error.is_some() {
continue;
}
let content = format!(
"status: {}\n\
branch: {}\n\
priority: {}\n\
uncommitted: {}\n\
last-commit: {}\n\
last-message: {}\n\
staleness: {} days\n",
state.status,
state.branch,
state.priority,
state.uncommitted,
state.last_commit,
state.last_message,
state.staleness_days,
);
let path = dir.join(format!("{}.md", state.name));
fs::write(&path, &content)
.with_context(|| {
format!("Failed to write state: {}", path.display())
})?;
println!(
" {} {}",
"wrote:".green(),
path.display()
);
}
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
let config = load_config()?;
let states: Vec<ProjectState> = config
.project
.iter()
.map(|p| scan_project(p))
.collect();
if cli.json {
let json = serde_json::to_string_pretty(&states)?;
println!("{}", json);
} else {
print_pretty(&states);
if cli.update {
println!("{}", " Writing state files...".dimmed());
write_state_files(&states)?;
}
}
Ok(())
}