Skip to main content

car_server_core/coder/
project.rs

1//! CAR-managed projects — the non-developer's unit of work.
2//!
3//! A non-dev doesn't have (or want to pick) a git repo. A **project** is a
4//! named, CAR-managed git repository under `~/.car/projects/<slug>/`: created
5//! and initialized for them, so the coder's worktree/branch machinery works
6//! underneath while the user only ever sees a name. A project has a **kind** —
7//! `App` (generic code) or `Agent` (a declarative CAR agent, Stage 2) — which
8//! decides what gets seeded and how an approved session is delivered.
9//!
10//! This is distinct from `car_memgine::project::scaffold_project`, which
11//! scaffolds a `<repo>/.car/` *team-metadata* directory; we never call it.
12
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16/// What a project produces, which selects seeding + the contract style.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ProjectKind {
20    /// Generic code: the coder's normal shell/file loop, model-derived
21    /// outcome contract, delivered to `main`.
22    App,
23    /// A declarative CAR agent: seeded with an `agent.json` + `scenarios.json`
24    /// the coder fills in; the contract is "all scenarios pass"; approving
25    /// registers the agent so it runs in-daemon (Stage 2).
26    Agent,
27}
28
29impl ProjectKind {
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Self::App => "app",
33            Self::Agent => "agent",
34        }
35    }
36
37    pub fn parse(s: &str) -> Result<Self, String> {
38        match s.trim() {
39            "app" | "" => Ok(Self::App),
40            "agent" => Ok(Self::Agent),
41            other => Err(format!(
42                "unknown project kind '{other}' (expected app | agent)"
43            )),
44        }
45    }
46}
47
48/// A CAR-managed project. The git repo IS `repo_path`; this metadata lives
49/// beside it in `project.json` (gitignored).
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct CoderProject {
52    pub slug: String,
53    pub display_name: String,
54    pub kind: ProjectKind,
55    pub repo_path: PathBuf,
56    pub created_at: u64,
57}
58
59fn now_secs() -> u64 {
60    std::time::SystemTime::now()
61        .duration_since(std::time::UNIX_EPOCH)
62        .map(|d| d.as_secs())
63        .unwrap_or(0)
64}
65
66/// `projects` under the CAR state root (`~/.car/projects` unless `CAR_HOME`
67/// moves the root) — managed project repos. `CAR_PROJECTS_DIR` still overrides
68/// both for tests and embedders (mirroring `CAR_CODER_STATE_DIR`).
69pub fn projects_root() -> Result<PathBuf, String> {
70    if let Some(dir) = std::env::var_os("CAR_PROJECTS_DIR") {
71        return Ok(PathBuf::from(dir));
72    }
73    let root = car_home::root()
74        .ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
75    Ok(root.join("projects"))
76}
77
78/// Hard cap on a slugified id. Registration (`car-registry`'s supervisor
79/// `validate_id`) requires every agent id to also be a legal car-peers
80/// peer name, whose own hard cap is 128 chars (`is_valid_peer_name`) —
81/// "two validators with two answers" is the exact drift that doc comment
82/// records happening once before. `slugify()` derives ids from free-text
83/// descriptions, so it caps well under that ceiling. Long names that
84/// would exceed the cap keep a 64-bit digest suffix of the full slug so
85/// distinct long names stay distinct with collision-resistant odds
86/// (car#1492).
87const SLUG_MAX_LEN: usize = 64;
88
89/// Hex chars appended to an over-cap slug (sha256 of the full slug):
90/// 16 hex chars = 64 bits of digest, so long names that share the
91/// truncated prefix stay distinct with collision-resistant odds (a
92/// birthday collision needs ~2^32 same-prefix names). The round-1
93/// value of 6 (24 bits) collided on a real pair — "a" × 60 +
94/// "-candidate-665" and "-candidate-880" both slugified to "a" × 57 +
95/// "-066649", so the second request silently loaded the first one's
96/// project (car#1492).
97const SLUG_SUFFIX_HEX_LEN: usize = 16;
98
99/// Turn a human name into a filename-safe slug: lowercase, runs of
100/// non-`[a-z0-9]` collapse to a single `-`, trimmed. Empty → "project".
101/// Matches the supervisor's filename-safe id alphabet so a project slug is a
102/// legal agent id too (Stage 2 derives agent ids from project slugs).
103/// Names longer than [`SLUG_MAX_LEN`] chars are truncated and suffixed
104/// with a digest of the full slug, so a long description always yields an
105/// id that every CAR surface accepts — including the 128-char peer-name
106/// rule registration enforces — and distinct long names stay distinct
107/// with collision-resistant odds.
108pub fn slugify(name: &str) -> String {
109    let mut out = String::new();
110    let mut prev_dash = false;
111    for c in name.trim().chars() {
112        if c.is_ascii_alphanumeric() {
113            out.push(c.to_ascii_lowercase());
114            prev_dash = false;
115        } else if !prev_dash && !out.is_empty() {
116            out.push('-');
117            prev_dash = true;
118        }
119    }
120    let trimmed = out.trim_matches('-');
121    if trimmed.is_empty() {
122        return "project".to_string();
123    }
124    // Short slugs stay byte-identical to the historical form.
125    if trimmed.len() <= SLUG_MAX_LEN {
126        return trimmed.to_string();
127    }
128    // The slug is pure ASCII at this point, so byte slicing is
129    // char-boundary safe. Budget: prefix + '-' + suffix == SLUG_MAX_LEN.
130    let keep = SLUG_MAX_LEN - SLUG_SUFFIX_HEX_LEN - 1;
131    let mut capped = trimmed[..keep].trim_end_matches('-').to_string();
132    // The digest is of the FULL slug, not the raw name: names that
133    // normalize identically (case, punctuation) must keep resolving to
134    // the same id, which resolve_or_create_project's idempotency (same
135    // slug → same project dir) depends on.
136    use sha2::{Digest, Sha256};
137    let digest = Sha256::digest(trimmed.as_bytes());
138    capped.push('-');
139    for byte in &digest[..SLUG_SUFFIX_HEX_LEN / 2] {
140        capped.push_str(&format!("{byte:02x}"));
141    }
142    capped
143}
144
145fn project_dir(slug: &str) -> Result<PathBuf, String> {
146    Ok(projects_root()?.join(slug))
147}
148
149/// Process-wide lock serializing tests that mutate the `CAR_PROJECTS_DIR`
150/// global env var (across this module and the rpc tests). Never locked in
151/// production — projects_root just reads the var.
152#[cfg(test)]
153pub(crate) fn projects_env_lock() -> &'static std::sync::Mutex<()> {
154    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
155    LOCK.get_or_init(|| std::sync::Mutex::new(()))
156}
157
158fn git(dir: &Path, args: &[&str]) -> Result<(), String> {
159    let out = std::process::Command::new("git")
160        .arg("-C")
161        .arg(dir)
162        .args(args)
163        .output()
164        .map_err(|e| format!("git {args:?}: {e}"))?;
165    if out.status.success() {
166        Ok(())
167    } else {
168        Err(format!(
169            "git {args:?} failed: {}",
170            String::from_utf8_lossy(&out.stderr).trim()
171        ))
172    }
173}
174
175/// Resolve a project by name, creating + initializing it if absent. Idempotent:
176/// an existing slug loads its `project.json` (the requested `kind` is ignored
177/// for an existing project — its persisted kind wins). New projects get a git
178/// repo (`git init -b main`), kind-appropriate seed files, an initial commit,
179/// and a `project.json`.
180pub fn resolve_or_create_project(name: &str, kind: ProjectKind) -> Result<CoderProject, String> {
181    let slug = slugify(name);
182    let dir = project_dir(&slug)?;
183    let meta_path = dir.join("project.json");
184
185    if meta_path.exists() {
186        return load_project(&slug);
187    }
188
189    std::fs::create_dir_all(&dir)
190        .map_err(|e| format!("create project dir {}: {e}", dir.display()))?;
191    git(&dir, &["init", "-q", "-b", "main"])?;
192
193    // `.gitignore` keeps the project-local metadata out of the tracked tree.
194    std::fs::write(dir.join(".gitignore"), "project.json\n")
195        .map_err(|e| format!("write .gitignore: {e}"))?;
196
197    seed_project(&dir, name, kind)?;
198
199    git(
200        &dir,
201        &[
202            "-c",
203            "user.name=car-coder",
204            "-c",
205            "user.email=coder@parslee.ai",
206            "add",
207            "-A",
208        ],
209    )?;
210    git(
211        &dir,
212        &[
213            "-c",
214            "user.name=car-coder",
215            "-c",
216            "user.email=coder@parslee.ai",
217            "commit",
218            "-q",
219            "-m",
220            "Initialize project",
221        ],
222    )?;
223
224    let project = CoderProject {
225        slug: slug.clone(),
226        display_name: name.trim().to_string(),
227        kind,
228        repo_path: dir.clone(),
229        created_at: now_secs(),
230    };
231    persist(&project)?;
232    Ok(project)
233}
234
235/// Seed the working tree for a new project. App: a README. Agent: the starter
236/// `agent.json` + `scenarios.json` + `.car/identity.md` the coder will fill in
237/// (the declarative spec shape lands in Stage 2; this writes neutral stubs).
238fn seed_project(dir: &Path, name: &str, kind: ProjectKind) -> Result<(), String> {
239    let write = |rel: &str, body: &str| -> Result<(), String> {
240        let path = dir.join(rel);
241        if let Some(parent) = path.parent() {
242            std::fs::create_dir_all(parent)
243                .map_err(|e| format!("create {}: {e}", parent.display()))?;
244        }
245        std::fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))
246    };
247    let display = name.trim();
248    write(
249        "README.md",
250        &format!("# {display}\n\nA CAR-managed project.\n"),
251    )?;
252    if kind == ProjectKind::Agent {
253        // Neutral stubs — Stage 2 replaces these with the DeclarativeAgentSpec
254        // shape and the coder loop fills them in.
255        write(
256            "agent.json",
257            "{\n  \"name\": \"\",\n  \"identity\": \"\",\n  \"tools\": [],\n  \"standing_goal\": \"\"\n}\n",
258        )?;
259        write("scenarios.json", "[]\n")?;
260        write(
261            ".car/identity.md",
262            &format!("# {display}\n\nDescribe what this agent does.\n"),
263        )?;
264    }
265    Ok(())
266}
267
268fn persist(project: &CoderProject) -> Result<(), String> {
269    let path = project.repo_path.join("project.json");
270    let json = serde_json::to_string_pretty(project).map_err(|e| e.to_string())?;
271    std::fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display()))
272}
273
274/// Load a project's metadata by slug.
275pub fn load_project(slug: &str) -> Result<CoderProject, String> {
276    let path = project_dir(slug)?.join("project.json");
277    let text = std::fs::read_to_string(&path)
278        .map_err(|e| format!("no project '{slug}' ({}): {e}", path.display()))?;
279    serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
280}
281
282/// All managed projects, newest first.
283pub fn list_projects() -> Vec<CoderProject> {
284    let Ok(root) = projects_root() else {
285        return Vec::new();
286    };
287    let Ok(entries) = std::fs::read_dir(&root) else {
288        return Vec::new();
289    };
290    let mut out: Vec<CoderProject> = entries
291        .flatten()
292        .filter(|e| e.path().is_dir())
293        .filter_map(|e| e.file_name().into_string().ok())
294        .filter_map(|slug| load_project(&slug).ok())
295        .collect();
296    out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
297    out
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    /// Point CAR_PROJECTS_DIR at a temp dir for the duration of a closure.
305    /// Serialized via a process-wide lock since the env var is global.
306    fn with_temp_root<T>(f: impl FnOnce(&Path) -> T) -> T {
307        let _guard = super::projects_env_lock()
308            .lock()
309            .unwrap_or_else(|e| e.into_inner());
310        let tmp = tempfile::tempdir().unwrap();
311        let prev = std::env::var_os("CAR_PROJECTS_DIR");
312        unsafe {
313            std::env::set_var("CAR_PROJECTS_DIR", tmp.path());
314        }
315        let out = f(tmp.path());
316        unsafe {
317            match prev {
318                Some(v) => std::env::set_var("CAR_PROJECTS_DIR", v),
319                None => std::env::remove_var("CAR_PROJECTS_DIR"),
320            }
321        }
322        out
323    }
324
325    fn git_available() -> bool {
326        std::process::Command::new("git")
327            .arg("--version")
328            .output()
329            .is_ok()
330    }
331
332    #[test]
333    fn slugify_is_filename_safe_and_stable() {
334        assert_eq!(slugify("My Email Summarizer!"), "my-email-summarizer");
335        assert_eq!(slugify("  weird___name  "), "weird-name");
336        assert_eq!(slugify("Café ☕ Bot"), "caf-bot");
337        assert_eq!(slugify(""), "project");
338        assert_eq!(slugify("!!!"), "project");
339        assert_eq!(slugify("already-good-123"), "already-good-123");
340    }
341
342    /// car#1492: the id slugify() mints must be accepted wherever an agent
343    /// id is checked — the 128-char peer-name rule that registration
344    /// (car-registry's validate_id) enforces. If these two rules drift
345    /// apart again, this is the test that says so.
346    #[test]
347    fn slugify_caps_long_names_within_peer_name_limit() {
348        let long = "research assistant that summarizes every morning brief and files follow-ups "
349            .repeat(6);
350        assert!(
351            long.len() > 300,
352            "fixture must actually be long: {}",
353            long.len()
354        );
355        let slug = slugify(&long);
356        assert_eq!(
357            slug.len(),
358            SLUG_MAX_LEN,
359            "cap must engage for a 300-char name"
360        );
361        assert!(car_peers::is_valid_peer_name(&slug));
362    }
363
364    /// Truncation alone would make any two long names sharing a prefix
365    /// collide; the digest suffix keeps them distinct.
366    #[test]
367    fn slugify_keeps_distinct_long_names_distinct() {
368        let base = "a".repeat(300);
369        let left = slugify(&format!("{base} left"));
370        let right = slugify(&format!("{base} right"));
371        assert_ne!(left, right);
372        assert!(car_peers::is_valid_peer_name(&left));
373        assert!(car_peers::is_valid_peer_name(&right));
374    }
375
376    /// car#1492 round 2: the round-1 6-hex (24-bit) suffix collided on
377    /// this exact pair, found by Codex's review — both names slugified
378    /// to "a" × 57 + "-066649", so creating the second project silently
379    /// loaded the first at resolve_or_create_project. The 16-hex
380    /// (64-bit) suffix must keep the pair apart.
381    #[test]
382    fn slugify_24_bit_collision_pair_stays_distinct() {
383        let left = slugify(&format!("{}-candidate-665", "a".repeat(60)));
384        let right = slugify(&format!("{}-candidate-880", "a".repeat(60)));
385        assert_ne!(left, right);
386        assert_eq!(left.len(), SLUG_MAX_LEN);
387        assert_eq!(right.len(), SLUG_MAX_LEN);
388        assert!(car_peers::is_valid_peer_name(&left));
389        assert!(car_peers::is_valid_peer_name(&right));
390    }
391
392    /// At the cap the output is byte-identical to the uncapped slug; one
393    /// char over, the cap engages and the result stays a legal peer name.
394    #[test]
395    fn slugify_cap_boundary_is_exact() {
396        let at_cap = "x".repeat(SLUG_MAX_LEN);
397        assert_eq!(slugify(&at_cap), at_cap);
398        let over = "y".repeat(SLUG_MAX_LEN + 1);
399        let capped = slugify(&over);
400        assert_eq!(capped.len(), SLUG_MAX_LEN);
401        assert!(capped.starts_with(&"y".repeat(SLUG_MAX_LEN - SLUG_SUFFIX_HEX_LEN - 1)));
402        assert!(car_peers::is_valid_peer_name(&capped));
403    }
404
405    /// resolve_or_create_project's idempotency depends on names that
406    /// normalize identically resolving to the same slug even past the
407    /// cap (the digest is of the full slug, not the raw name).
408    #[test]
409    fn slugify_truncation_follows_normalization() {
410        let lower = slugify(&"Thing One ".repeat(30));
411        let upper = slugify(&"THING ONE ".repeat(30));
412        assert_eq!(lower, upper);
413    }
414
415    #[test]
416    fn long_agent_description_gets_a_bounded_unique_slug_without_losing_the_description() {
417        if !git_available() {
418            return;
419        }
420        with_temp_root(|root| {
421            let stem = "Build an agent that summarizes every support conversation, preserves customer commitments, identifies unresolved follow-up work, and writes focused scenarios. ";
422            let mut description = stem.to_string();
423            description.push_str(&"x".repeat(400 - description.len()));
424            assert_eq!(description.chars().count(), 400);
425
426            let project = resolve_or_create_project(&description, ProjectKind::Agent)
427                .expect("create project");
428            assert!(
429                project.slug.len() <= SLUG_MAX_LEN,
430                "bounded slug was {} bytes: {}",
431                project.slug.len(),
432                project.slug
433            );
434            assert_eq!(project.repo_path.parent(), Some(root));
435            assert_eq!(project.display_name, description);
436            assert_eq!(
437                project.repo_path.file_name().and_then(|name| name.to_str()),
438                Some(project.slug.as_str())
439            );
440
441            assert_eq!(project.slug, slugify(&description));
442            let repeated = resolve_or_create_project(&description, ProjectKind::Agent).unwrap();
443            let equivalent =
444                resolve_or_create_project(&description.to_uppercase(), ProjectKind::Agent).unwrap();
445            assert_eq!(repeated.repo_path, project.repo_path);
446            assert_eq!(equivalent.repo_path, project.repo_path);
447
448            let mut distinct_description = description.clone();
449            distinct_description.replace_range(399..400, "y");
450            let distinct = resolve_or_create_project(&distinct_description, ProjectKind::Agent)
451                .expect("create distinct project");
452            assert_ne!(project.slug, distinct.slug);
453            assert_eq!(distinct.display_name, distinct_description);
454        });
455    }
456
457    #[test]
458    fn existing_agent_slugs_at_61_through_64_chars_keep_their_identity() {
459        if !git_available() {
460            return;
461        }
462        with_temp_root(|_| {
463            for length in 61..=64 {
464                let description = "a".repeat(length);
465                let project = resolve_or_create_project(&description, ProjectKind::Agent).unwrap();
466                assert_eq!(project.slug, description);
467                let repeated =
468                    resolve_or_create_project(&description.to_uppercase(), ProjectKind::Agent)
469                        .unwrap();
470                assert_eq!(project.repo_path, repeated.repo_path);
471            }
472        });
473    }
474
475    #[test]
476    fn create_initializes_git_repo_and_is_idempotent() {
477        if !git_available() {
478            return;
479        }
480        with_temp_root(|_root| {
481            let p = resolve_or_create_project("My App", ProjectKind::App).unwrap();
482            assert_eq!(p.slug, "my-app");
483            assert_eq!(p.kind, ProjectKind::App);
484            assert!(p.repo_path.join(".git").exists());
485            assert!(p.repo_path.join("README.md").exists());
486            assert!(p.repo_path.join("project.json").exists());
487            // Initial commit exists on main.
488            let log = std::process::Command::new("git")
489                .arg("-C")
490                .arg(&p.repo_path)
491                .args(["log", "--oneline"])
492                .output()
493                .unwrap();
494            assert!(log.status.success() && !log.stdout.is_empty());
495
496            // Idempotent: same name → same project, kind preserved even if the
497            // caller passes a different kind.
498            let again = resolve_or_create_project("My App", ProjectKind::Agent).unwrap();
499            assert_eq!(again.slug, p.slug);
500            assert_eq!(again.kind, ProjectKind::App, "existing kind wins");
501            assert_eq!(again.created_at, p.created_at);
502        });
503    }
504
505    #[test]
506    fn agent_project_seeds_spec_stubs() {
507        if !git_available() {
508            return;
509        }
510        with_temp_root(|_root| {
511            let p = resolve_or_create_project("Email Bot", ProjectKind::Agent).unwrap();
512            assert!(p.repo_path.join("agent.json").exists());
513            assert!(p.repo_path.join("scenarios.json").exists());
514            assert!(p.repo_path.join(".car/identity.md").exists());
515            // project.json is gitignored — not in the tracked tree.
516            let tracked = std::process::Command::new("git")
517                .arg("-C")
518                .arg(&p.repo_path)
519                .args(["ls-files"])
520                .output()
521                .unwrap();
522            let files = String::from_utf8_lossy(&tracked.stdout);
523            assert!(files.contains("agent.json"));
524            assert!(
525                !files.contains("project.json"),
526                "project.json must be gitignored"
527            );
528        });
529    }
530
531    #[test]
532    fn list_returns_created_projects_newest_first() {
533        if !git_available() {
534            return;
535        }
536        with_temp_root(|_root| {
537            let a = resolve_or_create_project("Alpha", ProjectKind::App).unwrap();
538            let b = resolve_or_create_project("Beta", ProjectKind::Agent).unwrap();
539            let listed = list_projects();
540            assert_eq!(listed.len(), 2);
541            let slugs: Vec<&str> = listed.iter().map(|p| p.slug.as_str()).collect();
542            assert!(slugs.contains(&a.slug.as_str()));
543            assert!(slugs.contains(&b.slug.as_str()));
544        });
545    }
546
547    #[test]
548    fn load_missing_project_errors() {
549        with_temp_root(|_root| {
550            assert!(load_project("does-not-exist").is_err());
551        });
552    }
553
554    #[test]
555    fn project_kind_round_trips() {
556        assert_eq!(ProjectKind::parse("app").unwrap(), ProjectKind::App);
557        assert_eq!(ProjectKind::parse("agent").unwrap(), ProjectKind::Agent);
558        assert_eq!(ProjectKind::parse("").unwrap(), ProjectKind::App);
559        assert!(ProjectKind::parse("widget").is_err());
560        assert_eq!(ProjectKind::App.as_str(), "app");
561        assert_eq!(ProjectKind::Agent.as_str(), "agent");
562    }
563}