car-server-core 0.24.1

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! CAR-managed projects — the non-developer's unit of work.
//!
//! A non-dev doesn't have (or want to pick) a git repo. A **project** is a
//! named, CAR-managed git repository under `~/.car/projects/<slug>/`: created
//! and initialized for them, so the coder's worktree/branch machinery works
//! underneath while the user only ever sees a name. A project has a **kind** —
//! `App` (generic code) or `Agent` (a declarative CAR agent, Stage 2) — which
//! decides what gets seeded and how an approved session is delivered.
//!
//! This is distinct from `car_memgine::project::scaffold_project`, which
//! scaffolds a `<repo>/.car/` *team-metadata* directory; we never call it.

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

/// What a project produces, which selects seeding + the contract style.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectKind {
    /// Generic code: the coder's normal shell/file loop, model-derived
    /// outcome contract, delivered to `main`.
    App,
    /// A declarative CAR agent: seeded with an `agent.json` + `scenarios.json`
    /// the coder fills in; the contract is "all scenarios pass"; approving
    /// registers the agent so it runs in-daemon (Stage 2).
    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)")),
        }
    }
}

/// A CAR-managed project. The git repo IS `repo_path`; this metadata lives
/// beside it in `project.json` (gitignored).
#[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)
}

/// `~/.car/projects` — managed project repos. `CAR_PROJECTS_DIR` overrides for
/// tests and embedders (mirroring `CAR_CODER_STATE_DIR`).
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"))
}

/// Turn a human name into a filename-safe slug: lowercase, runs of
/// non-`[a-z0-9]` collapse to a single `-`, trimmed. Empty → "project".
/// Matches the supervisor's filename-safe id alphabet so a project slug is a
/// legal agent id too (Stage 2 derives agent ids from project slugs).
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))
}

/// Process-wide lock serializing tests that mutate the `CAR_PROJECTS_DIR`
/// global env var (across this module and the rpc tests). Never locked in
/// production — projects_root just reads the var.
#[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()
        ))
    }
}

/// Resolve a project by name, creating + initializing it if absent. Idempotent:
/// an existing slug loads its `project.json` (the requested `kind` is ignored
/// for an existing project — its persisted kind wins). New projects get a git
/// repo (`git init -b main`), kind-appropriate seed files, an initial commit,
/// and a `project.json`.
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"])?;

    // `.gitignore` keeps the project-local metadata out of the tracked tree.
    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)
}

/// Seed the working tree for a new project. App: a README. Agent: the starter
/// `agent.json` + `scenarios.json` + `.car/identity.md` the coder will fill in
/// (the declarative spec shape lands in Stage 2; this writes neutral stubs).
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 {
        // Neutral stubs — Stage 2 replaces these with the DeclarativeAgentSpec
        // shape and the coder loop fills them in.
        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()))
}

/// Load a project's metadata by slug.
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()))
}

/// All managed projects, newest first.
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::*;

    /// Point CAR_PROJECTS_DIR at a temp dir for the duration of a closure.
    /// Serialized via a process-wide lock since the env var is global.
    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());
            // Initial commit exists on main.
            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());

            // Idempotent: same name → same project, kind preserved even if the
            // caller passes a different kind.
            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());
            // project.json is gitignored — not in the tracked tree.
            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");
    }
}