newt-core 0.7.1

Newt-Agent core types, errors, and the NeMoCode-style tier router
Documentation
//! `.scratch/` — the project-local **ephemeral** state root (#844), and its
//! configurable relocation.
//!
//! Three Cs:
//! - **Convention:** `.newt/` holds *tracked* per-repo newt config
//!   (`config.toml`, `agent-identity.toml`, `plan.md`, `language-packs/`);
//!   generated/ephemeral state (crew worktrees, the crew cargo target,
//!   per-session plans) defaults to a sibling **`.scratch/`**.
//! - **Configuration:** that location is NOT hard-coded. The scratch dir is
//!   resolved `NEWT_SCRATCH_DIR` (env) → `[scratch] dir` (config file) → the
//!   `.scratch` default, and may be **absolute** — so an environment where the
//!   checkout is read-only can point scratch at `/tmp`/`/var/tmp`, and a k8s
//!   deploy can point it at a PVC mount, exactly as git worktrees conventionally
//!   live outside the repo tree.
//! - **Composition:** every scratch consumer goes through [`scratch_root`] /
//!   [`ensure_scratch`], so the location is decided in one place.
//!
//! **The ignore is baked into the harness, not the model.** [`ensure_scratch`]
//! writes `<root>/.gitignore` containing `*` (like cargo's `target/.gitignore`),
//! so scratch is ignored independent of the repo's root `.gitignore`. (Moot when
//! scratch is relocated outside the repo, but harmless + correct in-tree.)

use std::io;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

/// The default ephemeral dir, relative to the repo root. A convention, not a
/// lock-in — override it via the `[scratch] dir` config key or `NEWT_SCRATCH_DIR`.
pub const DEFAULT_SCRATCH_DIR: &str = ".scratch";

/// The `[scratch] dir` config value, published once when config resolves
/// ([`set_scratch_dir`]). The env var still wins over it at read time.
static CONFIGURED_SCRATCH_DIR: OnceLock<String> = OnceLock::new();

/// Publish the config-file scratch dir (`[scratch] dir`), once. First non-empty
/// value wins; a no-op for `None`/empty. Called from `Config::resolve` so every
/// config-loading entry point picks it up. `NEWT_SCRATCH_DIR` still overrides.
pub fn set_scratch_dir(dir: impl Into<String>) {
    let dir = dir.into();
    if !dir.trim().is_empty() {
        let _ = CONFIGURED_SCRATCH_DIR.set(dir);
    }
}

/// The configured scratch dir, by precedence:
/// `NEWT_SCRATCH_DIR` env → `[scratch] dir` config → `.scratch` default. The
/// result may be relative (joined under the repo) or **absolute** (used as-is),
/// so a read-only checkout or a k8s deploy can point scratch at `/tmp`, a PVC
/// mount, or anywhere writable.
#[must_use]
pub fn scratch_dir() -> String {
    if let Ok(v) = std::env::var("NEWT_SCRATCH_DIR") {
        if !v.trim().is_empty() {
            return v;
        }
    }
    CONFIGURED_SCRATCH_DIR
        .get()
        .cloned()
        .unwrap_or_else(|| DEFAULT_SCRATCH_DIR.to_string())
}

/// Resolve a scratch dir setting against a repo `base`: an **absolute** setting
/// is used verbatim (relocated outside the repo — `/tmp`, a PVC, …); a
/// **relative** one is joined under `base`. Pure.
#[must_use]
pub fn resolve_scratch_root(base: &Path, dir: &str) -> PathBuf {
    let p = Path::new(dir);
    if p.is_absolute() {
        p.to_path_buf()
    } else {
        base.join(p)
    }
}

/// The ephemeral scratch root for repo `base`, honoring the configured location.
#[must_use]
pub fn scratch_root(base: &Path) -> PathBuf {
    resolve_scratch_root(base, &scratch_dir())
}

/// The workspace-RELATIVE scratch subdir for state that must stay inside the
/// file-write fence (per-session plans). Uses the configured dir when it is
/// relative; falls back to the default when scratch is relocated to an absolute
/// path (a fenced write can't escape the workspace — moving session plans out to
/// an absolute scratch is a follow-up that needs the fs fence to admit it).
#[must_use]
pub fn scratch_workspace_subdir() -> String {
    let dir = scratch_dir();
    if Path::new(&dir).is_absolute() {
        DEFAULT_SCRATCH_DIR.to_string()
    } else {
        dir
    }
}

/// Contents of the self-ignoring `<root>/.gitignore`. `*` ignores every file
/// under the scratch root regardless of the repo's root `.gitignore`, so the
/// harness owns the ignore and the model cannot forget it.
pub const SCRATCH_GITIGNORE: &str =
    "# Auto-generated by newt: ephemeral scratch (#844). Never commit this dir.\n*\n";

/// Create the scratch root for `base` and ensure its self-ignoring `.gitignore`
/// exists. Idempotent — call from every scratch-write path so scratch is *never*
/// committable, even if the root `.gitignore` is missing the entry. Returns the
/// resolved root.
///
/// # Errors
/// Propagates a directory-create or `.gitignore` write failure.
pub fn ensure_scratch(base: &Path) -> io::Result<PathBuf> {
    let root = scratch_root(base);
    std::fs::create_dir_all(&root)?;
    let ignore = root.join(".gitignore");
    if !ignore.exists() {
        std::fs::write(&ignore, SCRATCH_GITIGNORE)?;
    }
    Ok(root)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resolve_relative_setting_is_joined_under_base() {
        assert_eq!(
            resolve_scratch_root(Path::new("/repo"), ".scratch"),
            PathBuf::from("/repo/.scratch")
        );
        assert_eq!(
            resolve_scratch_root(Path::new("/repo"), ".myscratch"),
            PathBuf::from("/repo/.myscratch")
        );
    }

    #[test]
    fn resolve_absolute_setting_relocates_outside_the_repo() {
        // The read-only-checkout / k8s-PVC case: an absolute setting is used
        // verbatim, NOT joined under the repo.
        assert_eq!(
            resolve_scratch_root(Path::new("/repo"), "/tmp/newt-scratch"),
            PathBuf::from("/tmp/newt-scratch")
        );
    }

    #[test]
    fn workspace_subdir_falls_back_to_default_when_scratch_is_absolute() {
        // A fenced write can't escape the workspace, so an absolute scratch dir
        // keeps session plans on the relative default.
        // (scratch_dir() with no env/config set → the default, which is relative.)
        assert_eq!(scratch_workspace_subdir(), DEFAULT_SCRATCH_DIR);
    }

    #[test]
    fn gitignore_body_ignores_everything() {
        assert!(SCRATCH_GITIGNORE.lines().any(|l| l.trim() == "*"));
    }

    #[test]
    fn ensure_scratch_creates_dir_and_self_ignore() {
        let tmp = tempfile::tempdir().unwrap();
        let root = ensure_scratch(tmp.path()).unwrap();
        assert!(root.is_dir());
        let ignore = root.join(".gitignore");
        assert!(ignore.is_file());
        assert_eq!(std::fs::read_to_string(&ignore).unwrap(), SCRATCH_GITIGNORE);
    }

    #[test]
    fn ensure_scratch_is_idempotent_and_preserves_a_customized_ignore() {
        let tmp = tempfile::tempdir().unwrap();
        ensure_scratch(tmp.path()).unwrap();
        let ignore = scratch_root(tmp.path()).join(".gitignore");
        std::fs::write(&ignore, "*\n!keep.txt\n").unwrap();
        ensure_scratch(tmp.path()).unwrap();
        assert_eq!(std::fs::read_to_string(&ignore).unwrap(), "*\n!keep.txt\n");
    }
}