use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectKind {
App,
Agent,
}
impl ProjectKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::App => "app",
Self::Agent => "agent",
}
}
pub fn parse(s: &str) -> Result<Self, String> {
match s.trim() {
"app" | "" => Ok(Self::App),
"agent" => Ok(Self::Agent),
other => Err(format!("unknown project kind '{other}' (expected app | agent)")),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CoderProject {
pub slug: String,
pub display_name: String,
pub kind: ProjectKind,
pub repo_path: PathBuf,
pub created_at: u64,
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn projects_root() -> Result<PathBuf, String> {
if let Some(dir) = std::env::var_os("CAR_PROJECTS_DIR") {
return Ok(PathBuf::from(dir));
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or("cannot resolve home directory (HOME/USERPROFILE unset)")?;
Ok(PathBuf::from(home).join(".car").join("projects"))
}
pub fn slugify(name: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for c in name.trim().chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash && !out.is_empty() {
out.push('-');
prev_dash = true;
}
}
let trimmed = out.trim_matches('-');
if trimmed.is_empty() {
"project".to_string()
} else {
trimmed.to_string()
}
}
fn project_dir(slug: &str) -> Result<PathBuf, String> {
Ok(projects_root()?.join(slug))
}
#[cfg(test)]
pub(crate) fn projects_env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
fn git(dir: &Path, args: &[&str]) -> Result<(), String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map_err(|e| format!("git {args:?}: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(format!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
))
}
}
pub fn resolve_or_create_project(name: &str, kind: ProjectKind) -> Result<CoderProject, String> {
let slug = slugify(name);
let dir = project_dir(&slug)?;
let meta_path = dir.join("project.json");
if meta_path.exists() {
return load_project(&slug);
}
std::fs::create_dir_all(&dir).map_err(|e| format!("create project dir {}: {e}", dir.display()))?;
git(&dir, &["init", "-q", "-b", "main"])?;
std::fs::write(dir.join(".gitignore"), "project.json\n")
.map_err(|e| format!("write .gitignore: {e}"))?;
seed_project(&dir, name, kind)?;
git(
&dir,
&[
"-c",
"user.name=car-coder",
"-c",
"user.email=coder@parslee.ai",
"add",
"-A",
],
)?;
git(
&dir,
&[
"-c",
"user.name=car-coder",
"-c",
"user.email=coder@parslee.ai",
"commit",
"-q",
"-m",
"Initialize project",
],
)?;
let project = CoderProject {
slug: slug.clone(),
display_name: name.trim().to_string(),
kind,
repo_path: dir.clone(),
created_at: now_secs(),
};
persist(&project)?;
Ok(project)
}
fn seed_project(dir: &Path, name: &str, kind: ProjectKind) -> Result<(), String> {
let write = |rel: &str, body: &str| -> Result<(), String> {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
std::fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))
};
let display = name.trim();
write("README.md", &format!("# {display}\n\nA CAR-managed project.\n"))?;
if kind == ProjectKind::Agent {
write(
"agent.json",
"{\n \"name\": \"\",\n \"identity\": \"\",\n \"tools\": [],\n \"standing_goal\": \"\"\n}\n",
)?;
write("scenarios.json", "[]\n")?;
write(".car/identity.md", &format!("# {display}\n\nDescribe what this agent does.\n"))?;
}
Ok(())
}
fn persist(project: &CoderProject) -> Result<(), String> {
let path = project.repo_path.join("project.json");
let json = serde_json::to_string_pretty(project).map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display()))
}
pub fn load_project(slug: &str) -> Result<CoderProject, String> {
let path = project_dir(slug)?.join("project.json");
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("no project '{slug}' ({}): {e}", path.display()))?;
serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
}
pub fn list_projects() -> Vec<CoderProject> {
let Ok(root) = projects_root() else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(&root) else {
return Vec::new();
};
let mut out: Vec<CoderProject> = entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.filter_map(|slug| load_project(&slug).ok())
.collect();
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn with_temp_root<T>(f: impl FnOnce(&Path) -> T) -> T {
let _guard = super::projects_env_lock().lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let prev = std::env::var_os("CAR_PROJECTS_DIR");
unsafe {
std::env::set_var("CAR_PROJECTS_DIR", tmp.path());
}
let out = f(tmp.path());
unsafe {
match prev {
Some(v) => std::env::set_var("CAR_PROJECTS_DIR", v),
None => std::env::remove_var("CAR_PROJECTS_DIR"),
}
}
out
}
fn git_available() -> bool {
std::process::Command::new("git").arg("--version").output().is_ok()
}
#[test]
fn slugify_is_filename_safe_and_stable() {
assert_eq!(slugify("My Email Summarizer!"), "my-email-summarizer");
assert_eq!(slugify(" weird___name "), "weird-name");
assert_eq!(slugify("Café ☕ Bot"), "caf-bot");
assert_eq!(slugify(""), "project");
assert_eq!(slugify("!!!"), "project");
assert_eq!(slugify("already-good-123"), "already-good-123");
}
#[test]
fn create_initializes_git_repo_and_is_idempotent() {
if !git_available() {
return;
}
with_temp_root(|_root| {
let p = resolve_or_create_project("My App", ProjectKind::App).unwrap();
assert_eq!(p.slug, "my-app");
assert_eq!(p.kind, ProjectKind::App);
assert!(p.repo_path.join(".git").exists());
assert!(p.repo_path.join("README.md").exists());
assert!(p.repo_path.join("project.json").exists());
let log = std::process::Command::new("git")
.arg("-C")
.arg(&p.repo_path)
.args(["log", "--oneline"])
.output()
.unwrap();
assert!(log.status.success() && !log.stdout.is_empty());
let again = resolve_or_create_project("My App", ProjectKind::Agent).unwrap();
assert_eq!(again.slug, p.slug);
assert_eq!(again.kind, ProjectKind::App, "existing kind wins");
assert_eq!(again.created_at, p.created_at);
});
}
#[test]
fn agent_project_seeds_spec_stubs() {
if !git_available() {
return;
}
with_temp_root(|_root| {
let p = resolve_or_create_project("Email Bot", ProjectKind::Agent).unwrap();
assert!(p.repo_path.join("agent.json").exists());
assert!(p.repo_path.join("scenarios.json").exists());
assert!(p.repo_path.join(".car/identity.md").exists());
let tracked = std::process::Command::new("git")
.arg("-C")
.arg(&p.repo_path)
.args(["ls-files"])
.output()
.unwrap();
let files = String::from_utf8_lossy(&tracked.stdout);
assert!(files.contains("agent.json"));
assert!(!files.contains("project.json"), "project.json must be gitignored");
});
}
#[test]
fn list_returns_created_projects_newest_first() {
if !git_available() {
return;
}
with_temp_root(|_root| {
let a = resolve_or_create_project("Alpha", ProjectKind::App).unwrap();
let b = resolve_or_create_project("Beta", ProjectKind::Agent).unwrap();
let listed = list_projects();
assert_eq!(listed.len(), 2);
let slugs: Vec<&str> = listed.iter().map(|p| p.slug.as_str()).collect();
assert!(slugs.contains(&a.slug.as_str()));
assert!(slugs.contains(&b.slug.as_str()));
});
}
#[test]
fn load_missing_project_errors() {
with_temp_root(|_root| {
assert!(load_project("does-not-exist").is_err());
});
}
#[test]
fn project_kind_round_trips() {
assert_eq!(ProjectKind::parse("app").unwrap(), ProjectKind::App);
assert_eq!(ProjectKind::parse("agent").unwrap(), ProjectKind::Agent);
assert_eq!(ProjectKind::parse("").unwrap(), ProjectKind::App);
assert!(ProjectKind::parse("widget").is_err());
assert_eq!(ProjectKind::App.as_str(), "app");
assert_eq!(ProjectKind::Agent.as_str(), "agent");
}
}