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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub existing_agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub builder_draft: Option<car_registry::declarative::AgentBuilderDraft>,
}
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 root = car_home::root()
.ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
Ok(root.join("projects"))
}
const SLUG_MAX_LEN: usize = 64;
const SLUG_SUFFIX_HEX_LEN: usize = 16;
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() {
return "project".to_string();
}
if trimmed.len() <= SLUG_MAX_LEN {
return trimmed.to_string();
}
let keep = SLUG_MAX_LEN - SLUG_SUFFIX_HEX_LEN - 1;
let mut capped = trimmed[..keep].trim_end_matches('-').to_string();
use sha2::{Digest, Sha256};
let digest = Sha256::digest(trimmed.as_bytes());
capped.push('-');
for byte in &digest[..SLUG_SUFFIX_HEX_LEN / 2] {
capped.push_str(&format!("{byte:02x}"));
}
capped
}
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> {
resolve_or_create_project_for_agent(name, kind, None, None)
}
pub fn resolve_or_create_project_for_agent(
name: &str,
kind: ProjectKind,
existing_agent_id: Option<String>,
builder_draft: Option<car_registry::declarative::AgentBuilderDraft>,
) -> Result<CoderProject, String> {
if kind != ProjectKind::Agent && (existing_agent_id.is_some() || builder_draft.is_some()) {
return Err("existing_agent_id and builder_draft require an agent project".into());
}
let slug = slugify(name);
let dir = project_dir(&slug)?;
let meta_path = dir.join("project.json");
if meta_path.exists() {
let mut project = load_project(&slug)?;
if existing_agent_id.is_some() || builder_draft.is_some() {
if project.kind != ProjectKind::Agent {
return Err(format!(
"project '{}' is kind '{}'; agent edit metadata requires kind 'agent'",
project.slug,
project.kind.as_str()
));
}
if let (Some(bound), Some(requested)) = (
project.existing_agent_id.as_deref(),
existing_agent_id.as_deref(),
) {
if bound != requested {
return Err(format!(
"project '{}' is already bound to agent '{}' and cannot be rebound to '{}'",
project.slug, bound, requested
));
}
}
if existing_agent_id.is_some() {
project.existing_agent_id = existing_agent_id;
}
if builder_draft.is_some() {
project.builder_draft = builder_draft;
}
persist(&project)?;
}
return Ok(project);
}
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(),
existing_agent_id,
builder_draft,
};
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 descriptions_from_1_through_2000_chars_have_bounded_safe_slugs() {
let source: String = "Agent / WITH spaces_日本語-and.punctuation_0123456789!"
.chars()
.cycle()
.take(2000)
.collect();
assert_eq!(source.chars().count(), 2000);
for length in 1..=2000 {
let description: String = source.chars().take(length).collect();
let slug = slugify(&description);
assert!(
!slug.is_empty() && slug.len() <= SLUG_MAX_LEN,
"length {length} produced {} bytes: {slug}",
slug.len()
);
assert!(
slug.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'),
"length {length} produced an unsafe slug: {slug}"
);
assert!(!slug.starts_with('-'), "length {length}: {slug}");
assert!(!slug.ends_with('-'), "length {length}: {slug}");
assert!(!slug.contains("--"), "length {length}: {slug}");
assert!(
car_peers::is_valid_peer_name(&slug),
"length {length} produced an invalid peer name: {slug}"
);
}
}
#[test]
fn slugify_caps_long_names_within_peer_name_limit() {
let long = "research assistant that summarizes every morning brief and files follow-ups "
.repeat(6);
assert!(
long.len() > 300,
"fixture must actually be long: {}",
long.len()
);
let slug = slugify(&long);
assert_eq!(
slug.len(),
SLUG_MAX_LEN,
"cap must engage for a 300-char name"
);
assert!(car_peers::is_valid_peer_name(&slug));
}
#[test]
fn slugify_keeps_distinct_long_names_distinct() {
let base = "a".repeat(300);
let left = slugify(&format!("{base} left"));
let right = slugify(&format!("{base} right"));
assert_ne!(left, right);
assert!(car_peers::is_valid_peer_name(&left));
assert!(car_peers::is_valid_peer_name(&right));
}
#[test]
fn slugify_24_bit_collision_pair_stays_distinct() {
let left = slugify(&format!("{}-candidate-665", "a".repeat(60)));
let right = slugify(&format!("{}-candidate-880", "a".repeat(60)));
assert_ne!(left, right);
assert_eq!(left.len(), SLUG_MAX_LEN);
assert_eq!(right.len(), SLUG_MAX_LEN);
assert!(car_peers::is_valid_peer_name(&left));
assert!(car_peers::is_valid_peer_name(&right));
}
#[test]
fn slugify_cap_boundary_is_exact() {
let at_cap = "x".repeat(SLUG_MAX_LEN);
assert_eq!(slugify(&at_cap), at_cap);
let over = "y".repeat(SLUG_MAX_LEN + 1);
let capped = slugify(&over);
assert_eq!(capped.len(), SLUG_MAX_LEN);
assert!(capped.starts_with(&"y".repeat(SLUG_MAX_LEN - SLUG_SUFFIX_HEX_LEN - 1)));
assert!(car_peers::is_valid_peer_name(&capped));
}
#[test]
fn slugify_truncation_follows_normalization() {
let lower = slugify(&"Thing One ".repeat(30));
let upper = slugify(&"THING ONE ".repeat(30));
assert_eq!(lower, upper);
}
#[test]
fn long_agent_description_gets_a_bounded_unique_slug_without_losing_the_description() {
if !git_available() {
return;
}
with_temp_root(|root| {
let stem = "Build an agent that summarizes every support conversation, preserves customer commitments, identifies unresolved follow-up work, and writes focused scenarios. ";
let mut description = stem.to_string();
description.push_str(&"x".repeat(400 - description.len()));
assert_eq!(description.chars().count(), 400);
let project = resolve_or_create_project(&description, ProjectKind::Agent)
.expect("create project");
assert!(
project.slug.len() <= SLUG_MAX_LEN,
"bounded slug was {} bytes: {}",
project.slug.len(),
project.slug
);
assert_eq!(project.repo_path.parent(), Some(root));
assert_eq!(project.display_name, description);
assert_eq!(
project.repo_path.file_name().and_then(|name| name.to_str()),
Some(project.slug.as_str())
);
assert_eq!(project.slug, slugify(&description));
let repeated = resolve_or_create_project(&description, ProjectKind::Agent).unwrap();
let equivalent =
resolve_or_create_project(&description.to_uppercase(), ProjectKind::Agent).unwrap();
assert_eq!(repeated.repo_path, project.repo_path);
assert_eq!(equivalent.repo_path, project.repo_path);
let mut distinct_description = description.clone();
distinct_description.replace_range(399..400, "y");
let distinct = resolve_or_create_project(&distinct_description, ProjectKind::Agent)
.expect("create distinct project");
assert_ne!(project.slug, distinct.slug);
assert_eq!(distinct.display_name, distinct_description);
});
}
#[test]
fn existing_agent_slugs_at_61_through_64_chars_keep_their_identity() {
if !git_available() {
return;
}
with_temp_root(|_| {
for length in 61..=64 {
let description = "a".repeat(length);
let project = resolve_or_create_project(&description, ProjectKind::Agent).unwrap();
assert_eq!(project.slug, description);
let repeated =
resolve_or_create_project(&description.to_uppercase(), ProjectKind::Agent)
.unwrap();
assert_eq!(project.repo_path, repeated.repo_path);
}
});
}
#[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_persists_builder_draft_and_cannot_rebind_existing_agent() {
if !git_available() {
return;
}
with_temp_root(|_root| {
let draft = car_registry::declarative::AgentBuilderDraft {
template_id: "inboxBrief".into(),
name: "Inbox Brief".into(),
responsibility: "Summarize mail".into(),
example: "Flag replies due today".into(),
access: "Read connected email".into(),
cadence: "Weekday mornings".into(),
delivery: "Save in Work".into(),
privacy: "Never send automatically".into(),
};
let project = resolve_or_create_project_for_agent(
"Inbox Brief",
ProjectKind::Agent,
Some("inbox-agent".into()),
Some(draft.clone()),
)
.unwrap();
let loaded = load_project(&project.slug).unwrap();
assert_eq!(loaded.existing_agent_id.as_deref(), Some("inbox-agent"));
assert_eq!(loaded.builder_draft, Some(draft));
let error = resolve_or_create_project_for_agent(
"Inbox Brief",
ProjectKind::Agent,
Some("different-agent".into()),
None,
)
.unwrap_err();
assert!(error.contains("cannot be rebound"), "{error}");
});
}
#[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");
}
}