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    /// When present, an Agent-project build replaces this registered agent
58    /// instead of deriving a new id from the project slug.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub existing_agent_id: Option<String>,
61    /// Builder input staged with the project until the generated spec is
62    /// approved and registered. The registered spec remains authoritative.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub builder_draft: Option<car_registry::declarative::AgentBuilderDraft>,
65}
66
67fn now_secs() -> u64 {
68    std::time::SystemTime::now()
69        .duration_since(std::time::UNIX_EPOCH)
70        .map(|d| d.as_secs())
71        .unwrap_or(0)
72}
73
74/// `projects` under the CAR state root (`~/.car/projects` unless `CAR_HOME`
75/// moves the root) — managed project repos. `CAR_PROJECTS_DIR` still overrides
76/// both for tests and embedders (mirroring `CAR_CODER_STATE_DIR`).
77pub fn projects_root() -> Result<PathBuf, String> {
78    if let Some(dir) = std::env::var_os("CAR_PROJECTS_DIR") {
79        return Ok(PathBuf::from(dir));
80    }
81    let root = car_home::root()
82        .ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
83    Ok(root.join("projects"))
84}
85
86/// Hard cap on a slugified id. Registration (`car-registry`'s supervisor
87/// `validate_id`) requires every agent id to also be a legal car-peers
88/// peer name, whose own hard cap is 128 chars (`is_valid_peer_name`) —
89/// "two validators with two answers" is the exact drift that doc comment
90/// records happening once before. `slugify()` derives ids from free-text
91/// descriptions, so it caps well under that ceiling. Long names that
92/// would exceed the cap keep a 64-bit digest suffix of the full slug so
93/// distinct long names stay distinct with collision-resistant odds
94/// (car#1492).
95const SLUG_MAX_LEN: usize = 64;
96
97/// Hex chars appended to an over-cap slug (sha256 of the full slug):
98/// 16 hex chars = 64 bits of digest, so long names that share the
99/// truncated prefix stay distinct with collision-resistant odds (a
100/// birthday collision needs ~2^32 same-prefix names). The round-1
101/// value of 6 (24 bits) collided on a real pair — "a" × 60 +
102/// "-candidate-665" and "-candidate-880" both slugified to "a" × 57 +
103/// "-066649", so the second request silently loaded the first one's
104/// project (car#1492).
105const SLUG_SUFFIX_HEX_LEN: usize = 16;
106
107/// Turn a human name into a filename-safe slug: lowercase, runs of
108/// non-`[a-z0-9]` collapse to a single `-`, trimmed. Empty → "project".
109/// Matches the supervisor's filename-safe id alphabet so a project slug is a
110/// legal agent id too (Stage 2 derives agent ids from project slugs).
111/// Names longer than [`SLUG_MAX_LEN`] chars are truncated and suffixed
112/// with a digest of the full slug, so a long description always yields an
113/// id that every CAR surface accepts — including the 128-char peer-name
114/// rule registration enforces — and distinct long names stay distinct
115/// with collision-resistant odds.
116pub fn slugify(name: &str) -> String {
117    let mut out = String::new();
118    let mut prev_dash = false;
119    for c in name.trim().chars() {
120        if c.is_ascii_alphanumeric() {
121            out.push(c.to_ascii_lowercase());
122            prev_dash = false;
123        } else if !prev_dash && !out.is_empty() {
124            out.push('-');
125            prev_dash = true;
126        }
127    }
128    let trimmed = out.trim_matches('-');
129    if trimmed.is_empty() {
130        return "project".to_string();
131    }
132    // Short slugs stay byte-identical to the historical form.
133    if trimmed.len() <= SLUG_MAX_LEN {
134        return trimmed.to_string();
135    }
136    // The slug is pure ASCII at this point, so byte slicing is
137    // char-boundary safe. Budget: prefix + '-' + suffix == SLUG_MAX_LEN.
138    let keep = SLUG_MAX_LEN - SLUG_SUFFIX_HEX_LEN - 1;
139    let mut capped = trimmed[..keep].trim_end_matches('-').to_string();
140    // The digest is of the FULL slug, not the raw name: names that
141    // normalize identically (case, punctuation) must keep resolving to
142    // the same id, which resolve_or_create_project's idempotency (same
143    // slug → same project dir) depends on.
144    use sha2::{Digest, Sha256};
145    let digest = Sha256::digest(trimmed.as_bytes());
146    capped.push('-');
147    for byte in &digest[..SLUG_SUFFIX_HEX_LEN / 2] {
148        capped.push_str(&format!("{byte:02x}"));
149    }
150    capped
151}
152
153fn project_dir(slug: &str) -> Result<PathBuf, String> {
154    Ok(projects_root()?.join(slug))
155}
156
157/// Process-wide lock serializing tests that mutate the `CAR_PROJECTS_DIR`
158/// global env var (across this module and the rpc tests). Never locked in
159/// production — projects_root just reads the var.
160#[cfg(test)]
161pub(crate) fn projects_env_lock() -> &'static std::sync::Mutex<()> {
162    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
163    LOCK.get_or_init(|| std::sync::Mutex::new(()))
164}
165
166fn git(dir: &Path, args: &[&str]) -> Result<(), String> {
167    let out = std::process::Command::new("git")
168        .arg("-C")
169        .arg(dir)
170        .args(args)
171        .output()
172        .map_err(|e| format!("git {args:?}: {e}"))?;
173    if out.status.success() {
174        Ok(())
175    } else {
176        Err(format!(
177            "git {args:?} failed: {}",
178            String::from_utf8_lossy(&out.stderr).trim()
179        ))
180    }
181}
182
183/// Resolve a project by name, creating + initializing it if absent. Idempotent:
184/// an existing slug loads its `project.json` (the requested `kind` is ignored
185/// for an existing project — its persisted kind wins). New projects get a git
186/// repo (`git init -b main`), kind-appropriate seed files, an initial commit,
187/// and a `project.json`.
188pub fn resolve_or_create_project(name: &str, kind: ProjectKind) -> Result<CoderProject, String> {
189    resolve_or_create_project_for_agent(name, kind, None, None)
190}
191
192/// Resolve or create a project while carrying the Agent Builder inputs and an
193/// optional existing registered-agent identity through the coder pipeline.
194///
195/// Existing projects may refresh their staged builder draft, but cannot be
196/// rebound to a different registered agent id. That makes retry idempotent
197/// without turning a project slug into an overwrite primitive.
198pub fn resolve_or_create_project_for_agent(
199    name: &str,
200    kind: ProjectKind,
201    existing_agent_id: Option<String>,
202    builder_draft: Option<car_registry::declarative::AgentBuilderDraft>,
203) -> Result<CoderProject, String> {
204    if kind != ProjectKind::Agent && (existing_agent_id.is_some() || builder_draft.is_some()) {
205        return Err("existing_agent_id and builder_draft require an agent project".into());
206    }
207    let slug = slugify(name);
208    let dir = project_dir(&slug)?;
209    let meta_path = dir.join("project.json");
210
211    if meta_path.exists() {
212        let mut project = load_project(&slug)?;
213        if existing_agent_id.is_some() || builder_draft.is_some() {
214            if project.kind != ProjectKind::Agent {
215                return Err(format!(
216                    "project '{}' is kind '{}'; agent edit metadata requires kind 'agent'",
217                    project.slug,
218                    project.kind.as_str()
219                ));
220            }
221            if let (Some(bound), Some(requested)) = (
222                project.existing_agent_id.as_deref(),
223                existing_agent_id.as_deref(),
224            ) {
225                if bound != requested {
226                    return Err(format!(
227                        "project '{}' is already bound to agent '{}' and cannot be rebound to '{}'",
228                        project.slug, bound, requested
229                    ));
230                }
231            }
232            if existing_agent_id.is_some() {
233                project.existing_agent_id = existing_agent_id;
234            }
235            if builder_draft.is_some() {
236                project.builder_draft = builder_draft;
237            }
238            persist(&project)?;
239        }
240        return Ok(project);
241    }
242
243    std::fs::create_dir_all(&dir)
244        .map_err(|e| format!("create project dir {}: {e}", dir.display()))?;
245    git(&dir, &["init", "-q", "-b", "main"])?;
246
247    // `.gitignore` keeps the project-local metadata out of the tracked tree.
248    std::fs::write(dir.join(".gitignore"), "project.json\n")
249        .map_err(|e| format!("write .gitignore: {e}"))?;
250
251    seed_project(&dir, name, kind)?;
252
253    git(
254        &dir,
255        &[
256            "-c",
257            "user.name=car-coder",
258            "-c",
259            "user.email=coder@parslee.ai",
260            "add",
261            "-A",
262        ],
263    )?;
264    git(
265        &dir,
266        &[
267            "-c",
268            "user.name=car-coder",
269            "-c",
270            "user.email=coder@parslee.ai",
271            "commit",
272            "-q",
273            "-m",
274            "Initialize project",
275        ],
276    )?;
277
278    let project = CoderProject {
279        slug: slug.clone(),
280        display_name: name.trim().to_string(),
281        kind,
282        repo_path: dir.clone(),
283        created_at: now_secs(),
284        existing_agent_id,
285        builder_draft,
286    };
287    persist(&project)?;
288    Ok(project)
289}
290
291/// Seed the working tree for a new project. App: a README. Agent: the starter
292/// `agent.json` + `scenarios.json` + `.car/identity.md` the coder will fill in
293/// (the declarative spec shape lands in Stage 2; this writes neutral stubs).
294fn seed_project(dir: &Path, name: &str, kind: ProjectKind) -> Result<(), String> {
295    let write = |rel: &str, body: &str| -> Result<(), String> {
296        let path = dir.join(rel);
297        if let Some(parent) = path.parent() {
298            std::fs::create_dir_all(parent)
299                .map_err(|e| format!("create {}: {e}", parent.display()))?;
300        }
301        std::fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))
302    };
303    let display = name.trim();
304    write(
305        "README.md",
306        &format!("# {display}\n\nA CAR-managed project.\n"),
307    )?;
308    if kind == ProjectKind::Agent {
309        // Neutral stubs — Stage 2 replaces these with the DeclarativeAgentSpec
310        // shape and the coder loop fills them in.
311        write(
312            "agent.json",
313            "{\n  \"name\": \"\",\n  \"identity\": \"\",\n  \"tools\": [],\n  \"standing_goal\": \"\"\n}\n",
314        )?;
315        write("scenarios.json", "[]\n")?;
316        write(
317            ".car/identity.md",
318            &format!("# {display}\n\nDescribe what this agent does.\n"),
319        )?;
320    }
321    Ok(())
322}
323
324fn persist(project: &CoderProject) -> Result<(), String> {
325    let path = project.repo_path.join("project.json");
326    let json = serde_json::to_string_pretty(project).map_err(|e| e.to_string())?;
327    std::fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display()))
328}
329
330/// Load a project's metadata by slug.
331pub fn load_project(slug: &str) -> Result<CoderProject, String> {
332    let path = project_dir(slug)?.join("project.json");
333    let text = std::fs::read_to_string(&path)
334        .map_err(|e| format!("no project '{slug}' ({}): {e}", path.display()))?;
335    serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
336}
337
338/// All managed projects, newest first.
339pub fn list_projects() -> Vec<CoderProject> {
340    let Ok(root) = projects_root() else {
341        return Vec::new();
342    };
343    let Ok(entries) = std::fs::read_dir(&root) else {
344        return Vec::new();
345    };
346    let mut out: Vec<CoderProject> = entries
347        .flatten()
348        .filter(|e| e.path().is_dir())
349        .filter_map(|e| e.file_name().into_string().ok())
350        .filter_map(|slug| load_project(&slug).ok())
351        .collect();
352    out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
353    out
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    /// Point CAR_PROJECTS_DIR at a temp dir for the duration of a closure.
361    /// Serialized via a process-wide lock since the env var is global.
362    fn with_temp_root<T>(f: impl FnOnce(&Path) -> T) -> T {
363        let _guard = super::projects_env_lock()
364            .lock()
365            .unwrap_or_else(|e| e.into_inner());
366        let tmp = tempfile::tempdir().unwrap();
367        let prev = std::env::var_os("CAR_PROJECTS_DIR");
368        unsafe {
369            std::env::set_var("CAR_PROJECTS_DIR", tmp.path());
370        }
371        let out = f(tmp.path());
372        unsafe {
373            match prev {
374                Some(v) => std::env::set_var("CAR_PROJECTS_DIR", v),
375                None => std::env::remove_var("CAR_PROJECTS_DIR"),
376            }
377        }
378        out
379    }
380
381    fn git_available() -> bool {
382        std::process::Command::new("git")
383            .arg("--version")
384            .output()
385            .is_ok()
386    }
387
388    #[test]
389    fn slugify_is_filename_safe_and_stable() {
390        assert_eq!(slugify("My Email Summarizer!"), "my-email-summarizer");
391        assert_eq!(slugify("  weird___name  "), "weird-name");
392        assert_eq!(slugify("Café ☕ Bot"), "caf-bot");
393        assert_eq!(slugify(""), "project");
394        assert_eq!(slugify("!!!"), "project");
395        assert_eq!(slugify("already-good-123"), "already-good-123");
396    }
397
398    #[test]
399    fn descriptions_from_1_through_2000_chars_have_bounded_safe_slugs() {
400        let source: String = "Agent / WITH spaces_日本語-and.punctuation_0123456789!"
401            .chars()
402            .cycle()
403            .take(2000)
404            .collect();
405        assert_eq!(source.chars().count(), 2000);
406
407        for length in 1..=2000 {
408            let description: String = source.chars().take(length).collect();
409            let slug = slugify(&description);
410            assert!(
411                !slug.is_empty() && slug.len() <= SLUG_MAX_LEN,
412                "length {length} produced {} bytes: {slug}",
413                slug.len()
414            );
415            assert!(
416                slug.bytes()
417                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'),
418                "length {length} produced an unsafe slug: {slug}"
419            );
420            assert!(!slug.starts_with('-'), "length {length}: {slug}");
421            assert!(!slug.ends_with('-'), "length {length}: {slug}");
422            assert!(!slug.contains("--"), "length {length}: {slug}");
423            assert!(
424                car_peers::is_valid_peer_name(&slug),
425                "length {length} produced an invalid peer name: {slug}"
426            );
427        }
428    }
429
430    /// car#1492: the id slugify() mints must be accepted wherever an agent
431    /// id is checked — the 128-char peer-name rule that registration
432    /// (car-registry's validate_id) enforces. If these two rules drift
433    /// apart again, this is the test that says so.
434    #[test]
435    fn slugify_caps_long_names_within_peer_name_limit() {
436        let long = "research assistant that summarizes every morning brief and files follow-ups "
437            .repeat(6);
438        assert!(
439            long.len() > 300,
440            "fixture must actually be long: {}",
441            long.len()
442        );
443        let slug = slugify(&long);
444        assert_eq!(
445            slug.len(),
446            SLUG_MAX_LEN,
447            "cap must engage for a 300-char name"
448        );
449        assert!(car_peers::is_valid_peer_name(&slug));
450    }
451
452    /// Truncation alone would make any two long names sharing a prefix
453    /// collide; the digest suffix keeps them distinct.
454    #[test]
455    fn slugify_keeps_distinct_long_names_distinct() {
456        let base = "a".repeat(300);
457        let left = slugify(&format!("{base} left"));
458        let right = slugify(&format!("{base} right"));
459        assert_ne!(left, right);
460        assert!(car_peers::is_valid_peer_name(&left));
461        assert!(car_peers::is_valid_peer_name(&right));
462    }
463
464    /// car#1492 round 2: the round-1 6-hex (24-bit) suffix collided on
465    /// this exact pair, found by Codex's review — both names slugified
466    /// to "a" × 57 + "-066649", so creating the second project silently
467    /// loaded the first at resolve_or_create_project. The 16-hex
468    /// (64-bit) suffix must keep the pair apart.
469    #[test]
470    fn slugify_24_bit_collision_pair_stays_distinct() {
471        let left = slugify(&format!("{}-candidate-665", "a".repeat(60)));
472        let right = slugify(&format!("{}-candidate-880", "a".repeat(60)));
473        assert_ne!(left, right);
474        assert_eq!(left.len(), SLUG_MAX_LEN);
475        assert_eq!(right.len(), SLUG_MAX_LEN);
476        assert!(car_peers::is_valid_peer_name(&left));
477        assert!(car_peers::is_valid_peer_name(&right));
478    }
479
480    /// At the cap the output is byte-identical to the uncapped slug; one
481    /// char over, the cap engages and the result stays a legal peer name.
482    #[test]
483    fn slugify_cap_boundary_is_exact() {
484        let at_cap = "x".repeat(SLUG_MAX_LEN);
485        assert_eq!(slugify(&at_cap), at_cap);
486        let over = "y".repeat(SLUG_MAX_LEN + 1);
487        let capped = slugify(&over);
488        assert_eq!(capped.len(), SLUG_MAX_LEN);
489        assert!(capped.starts_with(&"y".repeat(SLUG_MAX_LEN - SLUG_SUFFIX_HEX_LEN - 1)));
490        assert!(car_peers::is_valid_peer_name(&capped));
491    }
492
493    /// resolve_or_create_project's idempotency depends on names that
494    /// normalize identically resolving to the same slug even past the
495    /// cap (the digest is of the full slug, not the raw name).
496    #[test]
497    fn slugify_truncation_follows_normalization() {
498        let lower = slugify(&"Thing One ".repeat(30));
499        let upper = slugify(&"THING ONE ".repeat(30));
500        assert_eq!(lower, upper);
501    }
502
503    #[test]
504    fn long_agent_description_gets_a_bounded_unique_slug_without_losing_the_description() {
505        if !git_available() {
506            return;
507        }
508        with_temp_root(|root| {
509            let stem = "Build an agent that summarizes every support conversation, preserves customer commitments, identifies unresolved follow-up work, and writes focused scenarios. ";
510            let mut description = stem.to_string();
511            description.push_str(&"x".repeat(400 - description.len()));
512            assert_eq!(description.chars().count(), 400);
513
514            let project = resolve_or_create_project(&description, ProjectKind::Agent)
515                .expect("create project");
516            assert!(
517                project.slug.len() <= SLUG_MAX_LEN,
518                "bounded slug was {} bytes: {}",
519                project.slug.len(),
520                project.slug
521            );
522            assert_eq!(project.repo_path.parent(), Some(root));
523            assert_eq!(project.display_name, description);
524            assert_eq!(
525                project.repo_path.file_name().and_then(|name| name.to_str()),
526                Some(project.slug.as_str())
527            );
528
529            assert_eq!(project.slug, slugify(&description));
530            let repeated = resolve_or_create_project(&description, ProjectKind::Agent).unwrap();
531            let equivalent =
532                resolve_or_create_project(&description.to_uppercase(), ProjectKind::Agent).unwrap();
533            assert_eq!(repeated.repo_path, project.repo_path);
534            assert_eq!(equivalent.repo_path, project.repo_path);
535
536            let mut distinct_description = description.clone();
537            distinct_description.replace_range(399..400, "y");
538            let distinct = resolve_or_create_project(&distinct_description, ProjectKind::Agent)
539                .expect("create distinct project");
540            assert_ne!(project.slug, distinct.slug);
541            assert_eq!(distinct.display_name, distinct_description);
542        });
543    }
544
545    #[test]
546    fn existing_agent_slugs_at_61_through_64_chars_keep_their_identity() {
547        if !git_available() {
548            return;
549        }
550        with_temp_root(|_| {
551            for length in 61..=64 {
552                let description = "a".repeat(length);
553                let project = resolve_or_create_project(&description, ProjectKind::Agent).unwrap();
554                assert_eq!(project.slug, description);
555                let repeated =
556                    resolve_or_create_project(&description.to_uppercase(), ProjectKind::Agent)
557                        .unwrap();
558                assert_eq!(project.repo_path, repeated.repo_path);
559            }
560        });
561    }
562
563    #[test]
564    fn create_initializes_git_repo_and_is_idempotent() {
565        if !git_available() {
566            return;
567        }
568        with_temp_root(|_root| {
569            let p = resolve_or_create_project("My App", ProjectKind::App).unwrap();
570            assert_eq!(p.slug, "my-app");
571            assert_eq!(p.kind, ProjectKind::App);
572            assert!(p.repo_path.join(".git").exists());
573            assert!(p.repo_path.join("README.md").exists());
574            assert!(p.repo_path.join("project.json").exists());
575            // Initial commit exists on main.
576            let log = std::process::Command::new("git")
577                .arg("-C")
578                .arg(&p.repo_path)
579                .args(["log", "--oneline"])
580                .output()
581                .unwrap();
582            assert!(log.status.success() && !log.stdout.is_empty());
583
584            // Idempotent: same name → same project, kind preserved even if the
585            // caller passes a different kind.
586            let again = resolve_or_create_project("My App", ProjectKind::Agent).unwrap();
587            assert_eq!(again.slug, p.slug);
588            assert_eq!(again.kind, ProjectKind::App, "existing kind wins");
589            assert_eq!(again.created_at, p.created_at);
590        });
591    }
592
593    #[test]
594    fn agent_project_persists_builder_draft_and_cannot_rebind_existing_agent() {
595        if !git_available() {
596            return;
597        }
598        with_temp_root(|_root| {
599            let draft = car_registry::declarative::AgentBuilderDraft {
600                template_id: "inboxBrief".into(),
601                name: "Inbox Brief".into(),
602                responsibility: "Summarize mail".into(),
603                example: "Flag replies due today".into(),
604                access: "Read connected email".into(),
605                cadence: "Weekday mornings".into(),
606                delivery: "Save in Work".into(),
607                privacy: "Never send automatically".into(),
608            };
609            let project = resolve_or_create_project_for_agent(
610                "Inbox Brief",
611                ProjectKind::Agent,
612                Some("inbox-agent".into()),
613                Some(draft.clone()),
614            )
615            .unwrap();
616            let loaded = load_project(&project.slug).unwrap();
617            assert_eq!(loaded.existing_agent_id.as_deref(), Some("inbox-agent"));
618            assert_eq!(loaded.builder_draft, Some(draft));
619
620            let error = resolve_or_create_project_for_agent(
621                "Inbox Brief",
622                ProjectKind::Agent,
623                Some("different-agent".into()),
624                None,
625            )
626            .unwrap_err();
627            assert!(error.contains("cannot be rebound"), "{error}");
628        });
629    }
630
631    #[test]
632    fn agent_project_seeds_spec_stubs() {
633        if !git_available() {
634            return;
635        }
636        with_temp_root(|_root| {
637            let p = resolve_or_create_project("Email Bot", ProjectKind::Agent).unwrap();
638            assert!(p.repo_path.join("agent.json").exists());
639            assert!(p.repo_path.join("scenarios.json").exists());
640            assert!(p.repo_path.join(".car/identity.md").exists());
641            // project.json is gitignored — not in the tracked tree.
642            let tracked = std::process::Command::new("git")
643                .arg("-C")
644                .arg(&p.repo_path)
645                .args(["ls-files"])
646                .output()
647                .unwrap();
648            let files = String::from_utf8_lossy(&tracked.stdout);
649            assert!(files.contains("agent.json"));
650            assert!(
651                !files.contains("project.json"),
652                "project.json must be gitignored"
653            );
654        });
655    }
656
657    #[test]
658    fn list_returns_created_projects_newest_first() {
659        if !git_available() {
660            return;
661        }
662        with_temp_root(|_root| {
663            let a = resolve_or_create_project("Alpha", ProjectKind::App).unwrap();
664            let b = resolve_or_create_project("Beta", ProjectKind::Agent).unwrap();
665            let listed = list_projects();
666            assert_eq!(listed.len(), 2);
667            let slugs: Vec<&str> = listed.iter().map(|p| p.slug.as_str()).collect();
668            assert!(slugs.contains(&a.slug.as_str()));
669            assert!(slugs.contains(&b.slug.as_str()));
670        });
671    }
672
673    #[test]
674    fn load_missing_project_errors() {
675        with_temp_root(|_root| {
676            assert!(load_project("does-not-exist").is_err());
677        });
678    }
679
680    #[test]
681    fn project_kind_round_trips() {
682        assert_eq!(ProjectKind::parse("app").unwrap(), ProjectKind::App);
683        assert_eq!(ProjectKind::parse("agent").unwrap(), ProjectKind::Agent);
684        assert_eq!(ProjectKind::parse("").unwrap(), ProjectKind::App);
685        assert!(ProjectKind::parse("widget").is_err());
686        assert_eq!(ProjectKind::App.as_str(), "app");
687        assert_eq!(ProjectKind::Agent.as_str(), "agent");
688    }
689}