trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
//! Seed `outputStyle`/`statusLine`/`attribution` defaults into the tm-owned
//! `CLAUDE_CONFIG_DIR` `settings.json` (defense-in-depth, issue #2214).
//!
//! Why: managed sessions currently rely on the PROJECT-tier `settings.json`
//! (written by `crate::core::session_launch::settings::write_output_style` /
//! `write_status_line`) plus `claude --setting-sources project,local` to show
//! the `trusty-mpm` output style and live statusline. That is a single point of
//! failure: if the project-tier write is ever skipped, races with a resumed
//! workspace, or the `--setting-sources` flag drifts, the tm-owned config dir
//! itself carries neither key and the session silently falls back to Claude
//! Code's own defaults. Seeding the SAME two keys directly into the tm-owned
//! `CLAUDE_CONFIG_DIR/settings.json` makes that config dir self-sufficient,
//! independent of the project tier.
//! What: [`ensure_settings_defaults`] reads the tm-owned
//! `<claude_config_dir>/settings.json` through the shared #7780 loader (absent
//! or whitespace-only → `{}`; unparseable or non-object → the original bytes are
//! copied aside first, #7789), seeds `outputStyle` only when absent, and sets
//! `statusLine` when absent OR when its existing command is stale — a binary
//! that is an ephemeral build path or no longer exists on disk (#2229). A
//! genuinely user-customized value pointing at an existing, non-ephemeral binary
//! is never overwritten; the file is written back atomically only when something
//! actually changed. The values reused are the exact same ones the project-tier
//! writer computes: [`crate::core::session_launch::OUTPUT_STYLE`]
//! (the default output-style id) and
//! [`crate::core::session_launch::resolve_statusline_command`] (the resolved
//! absolute `<tm-binary> statusline` command). It also seeds `attribution`
//! (#6807) with [`crate::core::attribution::ATTRIBUTION_FOOTER`] as both
//! `commit` and `pr`, so Claude Code appends the trusty-mpm footer itself and no
//! instruction prose has to restate it.
//! Test: `ensure_settings_defaults_seeds_fresh_file`,
//! `ensure_settings_defaults_preserves_existing_keys`,
//! `ensure_settings_defaults_does_not_overwrite_customized_values`,
//! `ensure_settings_defaults_seeds_attribution`,
//! `ensure_settings_defaults_preserves_operator_attribution`,
//! `ensure_settings_defaults_is_idempotent`.

use std::path::Path;

/// Seed `outputStyle`, `statusLine`, and `attribution` into
/// `<claude_config_dir>/settings.json` when a key is absent, preserving every
/// other key untouched.
///
/// Why: see the module-level doc comment — this is the defense-in-depth seed
/// called from [`super::global_config::ensure_global_config_dir`] on every
/// managed-driver bootstrap, so the tm-owned config dir never depends solely on
/// the project tier for these two keys.
/// What: reads the on-disk `settings.json` through
/// [`crate::core::session_launch::malformed_backup::load_settings_object`],
/// which starts from `{}` for an absent or whitespace-only file and, for an
/// unparseable or non-object one, copies the original bytes to a
/// `settings.json.malformed-<stamp>` sibling and warns naming that copy before
/// this function rewrites over it (#7789). A copy that cannot be written
/// abandons the write, leaving the file exactly as it was. It inserts
/// `outputStyle` =
/// [`crate::core::session_launch::OUTPUT_STYLE`] only if the key is not already
/// present, and sets `statusLine` = `{ "type": "command", "command":
/// <resolved absolute path> statusline", "padding": 0 }` (via
/// [`crate::core::session_launch::resolve_statusline_command`]) when the key is
/// absent OR when the existing command is stale — a binary that is an ephemeral
/// build path or no longer exists on disk (#2229), per
/// [`crate::core::session_launch::is_stale_statusline_command`]. A genuinely
/// user-customized `statusLine` pointing at an existing, non-ephemeral binary is
/// left untouched. Managed hook commands self-heal separately via
/// [`super::hooks::write_project_hooks`]'s replace-by-identity merge. It then
/// inserts `attribution` = `{ "commit": <footer>, "pr": <footer> }` from
/// [`crate::core::attribution::ATTRIBUTION_FOOTER`] only if the key is absent,
/// so an operator who set their own attribution keeps it (#6807). Writes back
/// via [`trusty_common::claude_config::write_json_atomic`] only when the merged
/// value actually differs from what was on disk (idempotent — no spurious
/// rewrite / mtime bump on repeat calls).
/// Test: `ensure_settings_defaults_seeds_fresh_file`,
/// `ensure_settings_defaults_preserves_existing_keys`,
/// `ensure_settings_defaults_does_not_overwrite_customized_values`,
/// `ensure_settings_defaults_heals_stale_ephemeral_statusline`,
/// `ensure_settings_defaults_preserves_existing_valid_binary_statusline`,
/// `ensure_settings_defaults_seeds_attribution`,
/// `ensure_settings_defaults_preserves_operator_attribution`,
/// `ensure_settings_defaults_is_idempotent`,
/// `ensure_settings_defaults_backs_up_a_malformed_file_before_rewriting_it`,
/// `ensure_settings_defaults_refuses_when_the_copy_cannot_be_written`,
/// `ensure_settings_defaults_takes_no_copy_for_a_valid_file`,
/// `the_managed_warning_names_the_copy_it_took`.
pub(crate) fn ensure_settings_defaults(claude_config_dir: &Path) -> anyhow::Result<()> {
    let settings_path = claude_config_dir.join("settings.json");

    // #7762: the read below and the write at the bottom are one critical
    // section. `tm doctor --fix` repairs this same managed file from another
    // PROCESS, and an interleaved cycle drops whichever writer read first.
    crate::core::settings_lock::with_settings_lock(&settings_path, || {
        ensure_settings_defaults_locked(&settings_path)
    })?
}

/// The read / seed / publish body of [`ensure_settings_defaults`], run under the
/// settings lock.
///
/// Why (#7762): the lock must span the read and the write; splitting keeps the
/// acquisition visible at the entry point.
/// What: exactly what [`ensure_settings_defaults`] documents.
/// Test: see [`ensure_settings_defaults`].
fn ensure_settings_defaults_locked(settings_path: &Path) -> anyhow::Result<()> {
    // #7789: this read used to be `.ok().filter(is_object)`, so an unparseable
    // or non-object managed settings.json became `{}` and the write below
    // discarded every key the operator had put there. The shared #7780 loader
    // copies the original bytes aside first and refuses the whole call when
    // that copy cannot be made.
    let original =
        crate::core::session_launch::malformed_backup::load_settings_object(settings_path)?;

    let mut settings = original.clone();
    let obj = settings
        .as_object_mut()
        .expect("load_settings_object only ever returns a JSON object");

    obj.entry("outputStyle").or_insert_with(|| {
        serde_json::Value::String(crate::core::session_launch::OUTPUT_STYLE.to_string())
    });

    // statusLine: seed when absent, and self-heal when the existing command
    // points at a binary that is an ephemeral build path or no longer exists on
    // disk (#2229). A genuinely user-customized command that still points at an
    // existing, non-ephemeral binary is left untouched by
    // `is_stale_statusline_command`.
    // #7617: the decision itself lives in `core::statusline_settings`, shared
    // with the project-tier and user-tier writers. Only the I/O differs here —
    // this function merges three keys into ONE atomic write, so it applies the
    // rule in memory rather than calling a writer that owns the file.
    let _ = crate::core::statusline_settings::apply_statusline_entry(obj);

    // #6807: the commit trailer and PR footer come from Claude Code's own
    // `attribution` setting, not from instruction prose. Seed only when absent
    // so an operator-set value survives.
    obj.entry("attribution").or_insert_with(|| {
        serde_json::json!({
            "commit": crate::core::attribution::ATTRIBUTION_FOOTER,
            "pr": crate::core::attribution::ATTRIBUTION_FOOTER,
        })
    });

    // Structural comparison (not byte-wise) so formatting differences left by
    // editors or prior writes never trigger a spurious rewrite, matching the
    // idempotency pattern used by `global_config::ensure_mcp_config`.
    let needs_write = settings != original;
    if needs_write {
        // #7762: `settings_lock::publish` rather than `write_json_atomic` — the
        // same stage-and-rename, without the `<path>.bak` copy nothing reads.
        crate::core::settings_lock::publish(settings_path, &settings)?;
    }
    Ok(())
}

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

    #[test]
    fn ensure_settings_defaults_seeds_fresh_file() {
        let tmp = TempDir::new().unwrap();
        let cfg = tmp.path().to_path_buf();

        ensure_settings_defaults(&cfg).unwrap();

        let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
        let val: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            val["outputStyle"].as_str(),
            Some(crate::core::session_launch::OUTPUT_STYLE),
            "outputStyle must be seeded with the tm default id"
        );
        let status_line = &val["statusLine"];
        assert_eq!(status_line["type"].as_str(), Some("command"));
        assert_eq!(status_line["padding"].as_i64(), Some(0));
        assert!(
            status_line["command"]
                .as_str()
                .is_some_and(|c| c.ends_with(" statusline")),
            "statusLine.command must resolve to '<abs path> statusline', got {:?}",
            status_line["command"]
        );
    }

    #[test]
    fn ensure_settings_defaults_preserves_existing_keys() {
        let tmp = TempDir::new().unwrap();
        let cfg = tmp.path().to_path_buf();
        std::fs::write(
            cfg.join("settings.json"),
            serde_json::to_string_pretty(&serde_json::json!({
                "theme": "dark",
                "model": "opusplan",
            }))
            .unwrap(),
        )
        .unwrap();

        ensure_settings_defaults(&cfg).unwrap();

        let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
        let val: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            val["theme"].as_str(),
            Some("dark"),
            "unrelated key preserved"
        );
        assert_eq!(
            val["model"].as_str(),
            Some("opusplan"),
            "unrelated key preserved"
        );
        assert_eq!(
            val["outputStyle"].as_str(),
            Some(crate::core::session_launch::OUTPUT_STYLE)
        );
        assert!(val["statusLine"].is_object());
    }

    #[test]
    fn ensure_settings_defaults_does_not_overwrite_customized_values() {
        let tmp = TempDir::new().unwrap();
        let cfg = tmp.path().to_path_buf();
        std::fs::write(
            cfg.join("settings.json"),
            serde_json::to_string_pretty(&serde_json::json!({
                "outputStyle": "teaching",
                "statusLine": { "type": "command", "command": "my-custom-line", "padding": 2 },
            }))
            .unwrap(),
        )
        .unwrap();

        ensure_settings_defaults(&cfg).unwrap();

        let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
        let val: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            val["outputStyle"].as_str(),
            Some("teaching"),
            "a pre-existing outputStyle must never be clobbered"
        );
        assert_eq!(
            val["statusLine"]["command"].as_str(),
            Some("my-custom-line"),
            "a pre-existing statusLine must never be clobbered"
        );
        assert_eq!(val["statusLine"]["padding"].as_i64(), Some(2));
    }

    #[test]
    fn ensure_settings_defaults_heals_stale_ephemeral_statusline() {
        // #2229: a statusLine.command baked from an ephemeral build path
        // (target/debug/deps) — the exact defect — must be re-resolved to a
        // stable command, NOT treated as untouchable customization.
        let tmp = TempDir::new().unwrap();
        let cfg = tmp.path().to_path_buf();
        let stale = "/tmp/target/debug/deps/tm-deadbeef statusline";
        std::fs::write(
            cfg.join("settings.json"),
            serde_json::to_string_pretty(&serde_json::json!({
                "statusLine": { "type": "command", "command": stale, "padding": 0 },
            }))
            .unwrap(),
        )
        .unwrap();

        ensure_settings_defaults(&cfg).unwrap();

        let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
        let val: serde_json::Value = serde_json::from_str(&text).unwrap();
        let healed = val["statusLine"]["command"].as_str().unwrap();
        assert_ne!(
            healed, stale,
            "a stale ephemeral statusLine must be rewritten"
        );
        assert!(
            !healed.contains("target/debug"),
            "healed command must not point at an ephemeral build path, got {healed}"
        );
        assert!(
            healed.ends_with(" statusline"),
            "healed command must still invoke the statusline subcommand, got {healed}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn ensure_settings_defaults_preserves_existing_valid_binary_statusline() {
        // A genuinely custom statusLine.command whose binary EXISTS on disk and
        // is not an ephemeral build path must be left completely untouched
        // (#2229 must not over-heal). `/bin/sh` exists on every supported unix.
        let tmp = TempDir::new().unwrap();
        let cfg = tmp.path().to_path_buf();
        let custom = "/bin/sh statusline";
        std::fs::write(
            cfg.join("settings.json"),
            serde_json::to_string_pretty(&serde_json::json!({
                "statusLine": { "type": "command", "command": custom, "padding": 3 },
            }))
            .unwrap(),
        )
        .unwrap();

        ensure_settings_defaults(&cfg).unwrap();

        let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
        let val: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            val["statusLine"]["command"].as_str(),
            Some(custom),
            "a valid, existing custom binary statusLine must never be clobbered"
        );
        assert_eq!(
            val["statusLine"]["padding"].as_i64(),
            Some(3),
            "the rest of a valid custom statusLine must survive untouched"
        );
    }

    #[test]
    fn ensure_settings_defaults_is_idempotent() {
        let tmp = TempDir::new().unwrap();
        let cfg = tmp.path().to_path_buf();

        ensure_settings_defaults(&cfg).unwrap();
        let bytes_first = std::fs::read(cfg.join("settings.json")).unwrap();

        ensure_settings_defaults(&cfg).unwrap();
        let bytes_second = std::fs::read(cfg.join("settings.json")).unwrap();

        assert_eq!(
            bytes_first, bytes_second,
            "a second call must not rewrite the file when nothing changed"
        );
    }

    /// #6807: a fresh settings.json gets `attribution.commit` and
    /// `attribution.pr`, both equal to the one shared footer constant.
    #[test]
    fn ensure_settings_defaults_seeds_attribution() {
        let tmp = TempDir::new().unwrap();
        let cfg = tmp.path().to_path_buf();

        ensure_settings_defaults(&cfg).unwrap();

        let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
        let val: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            val["attribution"]["commit"].as_str(),
            Some(crate::core::attribution::ATTRIBUTION_FOOTER),
            "attribution.commit must be the shared trusty-mpm footer"
        );
        assert_eq!(
            val["attribution"]["pr"].as_str(),
            Some(crate::core::attribution::ATTRIBUTION_FOOTER),
            "attribution.pr must be the shared trusty-mpm footer"
        );
    }

    /// #6807: seeding is absent-only, so an operator who set their own
    /// `attribution` is never overwritten.
    #[test]
    fn ensure_settings_defaults_preserves_operator_attribution() {
        let tmp = TempDir::new().unwrap();
        let cfg = tmp.path().to_path_buf();
        std::fs::write(
            cfg.join("settings.json"),
            r#"{"attribution":{"commit":"operator text","pr":""}}"#,
        )
        .unwrap();

        ensure_settings_defaults(&cfg).unwrap();

        let text = std::fs::read_to_string(cfg.join("settings.json")).unwrap();
        let val: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            val["attribution"]["commit"].as_str(),
            Some("operator text"),
            "an operator-set attribution.commit must survive the seed"
        );
        assert_eq!(
            val["attribution"]["pr"].as_str(),
            Some(""),
            "an operator-set attribution.pr must survive the seed"
        );
    }
}