newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
//! Filesystem migration with caller-owned diagnostic delivery.

use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};

use crate::tty::{Level, Notice};

use super::{migrate_config_text, migrate_persona_text, Migration};

/// A config lock-contention warning fires at most once per process. A single
/// command (e.g. `mcp import`, which re-reads the same authoritative config
/// while already holding its transaction lock) can hit lock contention on
/// the same target more than once; the operator needs to be told once that
/// this run's config changes were not durably saved, not the identical
/// sentence per attempt.
static LOCK_CONTENTION_REPORTED: AtomicBool = AtomicBool::new(false);

/// Reset the once-per-process dedupe. Test-only: the real "once per process"
/// promise needs a fresh process to observe, but the unit suite runs many
/// scenarios in one process and each must see its own first occurrence.
#[cfg(test)]
pub(crate) fn reset_lock_contention_warned_for_test() {
    LOCK_CONTENTION_REPORTED.store(false, Ordering::Relaxed);
}

/// Read and atomically migrate a persona. The caller owns presentation of the
/// report, including when a later persona parse fails.
pub fn read_persona_file(
    path: &Path,
    report: &mut dyn FnMut(Notice<'static>),
) -> std::io::Result<String> {
    read_migrating(
        MigratingPath {
            shown: path,
            operated: path,
        },
        "persona",
        migrate_persona_text,
        true,
        |p| std::fs::read_to_string(p),
        |p, text| crate::atomic_fs::atomic_write(p, text.as_bytes()),
        report,
    )
}

/// Read config under the same resolved-destination lock as Config::save.
/// A lock failure and the resulting in-memory load form one report. The lock
/// remains held through revalidation and replacement, exactly as before.
pub fn read_config_file(
    path: &Path,
    rewrite: bool,
    report: &mut dyn FnMut(Notice<'static>),
) -> std::io::Result<String> {
    let locked = rewrite.then(|| {
        let destination = crate::atomic_fs::ResolvedPath::resolve(path)?;
        let lock = crate::atomic_fs::acquire_lock(&destination.lock_path())?;
        anyhow::Ok((destination, lock))
    });
    let (locked, lock_failure) = match locked.transpose() {
        Ok(locked) => (locked, None),
        // Surface the lock-contention text at most once per process: a
        // single command that re-reads the same already-locked config more
        // than once (e.g. `mcp import`'s namespace-sanitization pass
        // re-loading a target it already holds the transaction lock on)
        // would otherwise repeat the identical sentence per attempt,
        // whether it lands in the fallback notice below or merged into a
        // migration notice above.
        Err(error) if !LOCK_CONTENTION_REPORTED.swap(true, Ordering::Relaxed) => (
            None,
            Some(format!(
                "cannot lock config {} for migration: {error:#}",
                path.display()
            )),
        ),
        Err(_) => (None, None),
    };
    let destination = locked.as_ref().map(|(destination, _)| destination);
    // Display the caller's path; operate on the resolved one. Canonicalization
    // can add a verbatim `\\?\` prefix (Windows), expand 8.3 short names, or
    // follow a symlinked directory, and the operator should be shown the path
    // they actually named.
    let operated = destination.map_or(path, crate::atomic_fs::ResolvedPath::as_path);
    let mut reported = false;
    let result = read_migrating(
        MigratingPath {
            shown: path,
            operated,
        },
        "config",
        migrate_config_text,
        destination.is_some(),
        |p| std::fs::read_to_string(p),
        |_, text| {
            destination
                .expect("rewrite holds the config lock")
                .atomic_write(text.as_bytes())
        },
        &mut |mut notice| {
            if let Some(error) = &lock_failure {
                notice.text = format!("{}; {error}", notice.text).into();
            }
            reported = true;
            report(notice);
        },
    );
    if let Some(error) = lock_failure.filter(|_| !reported) {
        let loaded = match &result {
            Ok(_) => {
                "loaded original text in memory; config changes from this command were not saved"
                    .to_string()
            }
            Err(read_error) => format!(
                "could not read original text: {read_error}; config changes from this command were not saved"
            ),
        };
        report(Notice::new(
            Level::Warn,
            "",
            format!("newt: warning: {error}; {loaded}"),
        ));
    }
    result
}

/// The path shown to the operator and the path actually read/written.
/// Canonicalization can add a verbatim `\\?\` prefix (Windows), expand 8.3
/// short names, or follow a symlinked directory; `shown` is what the
/// operator named, `operated` is what the filesystem calls resolved to.
pub(super) struct MigratingPath<'a> {
    pub(super) shown: &'a Path,
    pub(super) operated: &'a Path,
}

/// Injected filesystem seam. Reporting does not own stdout, stderr, a tracing
/// subscriber, or a terminal. A host may retain the value until a safe point.
pub(super) fn read_migrating(
    path: MigratingPath<'_>,
    kind: &str,
    migrate: fn(&str) -> Option<Migration>,
    rewrite: bool,
    mut read: impl FnMut(&Path) -> std::io::Result<String>,
    write: impl FnOnce(&Path, &str) -> anyhow::Result<()>,
    report: &mut dyn FnMut(Notice<'static>),
) -> std::io::Result<String> {
    let MigratingPath { shown, operated } = path;
    let raw = read(operated)?;
    let Some(migration) = migrate(&raw) else {
        return Ok(raw);
    };
    let changes = migration.changes.join(", ");
    let shown = shown.display();
    if !rewrite {
        report(Notice::new(
            Level::Warn,
            "",
            format!(
                "newt: warning: {kind} {shown} uses old psyche labels ({changes}); \
             loading translated original text in memory, the file is unchanged"
            ),
        ));
        return Ok(migration.text);
    }
    // Preserve the changed-source check: a non-cooperating editor may write
    // despite the lock. The latest bytes always win over our original snapshot.
    match read(operated) {
        Ok(current) if current != raw => {
            let latest = migrate(&current);
            let changes = latest.as_ref().map(|m| m.changes.join(", "));
            let detail = changes.map(|c| format!(" ({c})")).unwrap_or_default();
            report(Notice::new(
                Level::Warn,
                "",
                format!(
                    "newt: warning: {kind} {shown} changed during migration; \
                 loading latest text in memory{detail}, the file is unchanged"
                ),
            ));
            return Ok(latest.map_or(current, |latest| latest.text));
        }
        Err(error) => {
            report(Notice::new(
                Level::Warn,
                "",
                format!(
                    "newt: warning: cannot re-read {kind} {shown} before migration: {error}; \
                 loading translated original text ({changes}), the file is unchanged"
                ),
            ));
            return Ok(migration.text);
        }
        Ok(_) => {}
    }
    let notice = match write(operated, &migration.text) {
        Ok(()) => Notice::new(
            Level::Ok,
            "",
            format!("newt: migrated {kind} {shown}: {changes}; loaded translated original text"),
        ),
        Err(error) => Notice::new(
            Level::Warn,
            "",
            format!(
                "newt: warning: {kind} {shown} uses old psyche labels ({changes}); \
             loading translated original text, but migration write did not complete: {error:#}"
            ),
        ),
    };
    report(notice);
    Ok(migration.text)
}

#[cfg(test)]
#[path = "psyche_import_notice_tests.rs"]
mod tests;