jarvy 0.4.0

Jarvy is a fast, cross-platform CLI that installs and manages developer tools across macOS and Linux.
Documentation
//! Config migration and normalization
//!
//! Detects deprecated patterns in jarvy.toml and suggests or applies fixes.

use crate::output::{ExitCode, Outputable, colors, header};
use crate::tools::spec::get_tool_spec;
use serde::Serialize;
use std::fs;

/// A single migration suggestion
#[derive(Debug, Clone, Serialize)]
pub struct Migration {
    pub kind: String,
    pub message: String,
    pub line_hint: Option<String>,
    pub severity: String,
}

/// Migration report
#[derive(Debug, Clone, Serialize)]
pub struct MigrateReport {
    pub file: String,
    pub migrations: Vec<Migration>,
    pub applied: bool,
}

impl Outputable for MigrateReport {
    fn to_human(&self) -> String {
        let mut out = String::new();
        out.push_str(&header(&format!("Migration Report: {}", self.file)));
        out.push('\n');

        if self.migrations.is_empty() {
            out.push_str(&format!(
                "\n{}No migrations needed.{} Config is up to date.\n",
                colors::GREEN,
                colors::RESET
            ));
            return out;
        }

        for (i, m) in self.migrations.iter().enumerate() {
            let color = match m.severity.as_str() {
                "error" => colors::RED,
                "warning" => colors::YELLOW,
                _ => colors::CYAN,
            };
            out.push_str(&format!(
                "\n  {}{}. [{}]{} {}\n",
                color,
                i + 1,
                m.kind,
                colors::RESET,
                m.message
            ));
            if let Some(ref hint) = m.line_hint {
                out.push_str(&format!("     {}{}{}\n", colors::DIM, hint, colors::RESET));
            }
        }

        out.push_str(&format!(
            "\n{} migration(s) found.\n",
            self.migrations.len()
        ));

        if !self.applied {
            out.push_str(&format!(
                "{}Tip:{} Run with --apply to apply changes.\n",
                colors::DIM,
                colors::RESET
            ));
        }

        out
    }

    fn exit_code(&self) -> ExitCode {
        if self.migrations.iter().any(|m| m.severity == "error") {
            ExitCode::Error
        } else if !self.migrations.is_empty() {
            ExitCode::Warning
        } else {
            ExitCode::Ok
        }
    }
}

/// Analyze a jarvy.toml for migration needs. When `apply == true`,
/// auto-applicable migrations (today: the `[tools]` → `[provisioner]`
/// rename) are written back to `file` via an atomic tmp+rename.
/// Non-auto-applicable migrations (unknown-tool warnings,
/// unknown-hook-tool advisories) are still reported but never
/// silently rewrite — those need a human decision.
pub fn run_migrate(file: &str, apply: bool) -> MigrateReport {
    let content = match fs::read_to_string(file) {
        Ok(c) => c,
        Err(e) => {
            return MigrateReport {
                file: file.to_string(),
                migrations: vec![Migration {
                    kind: "error".to_string(),
                    message: format!("Cannot read {}: {}", file, e),
                    line_hint: None,
                    severity: "error".to_string(),
                }],
                applied: false,
            };
        }
    };

    let parsed: Result<toml::Value, _> = toml::from_str(&content);
    let Ok(config) = parsed else {
        return MigrateReport {
            file: file.to_string(),
            migrations: vec![Migration {
                kind: "parse-error".to_string(),
                message: format!("Invalid TOML: {}", parsed.unwrap_err()),
                line_hint: None,
                severity: "error".to_string(),
            }],
            applied: false,
        };
    };

    let mut migrations = Vec::new();

    // Check for unknown tool names in [provisioner]
    if let Some(provisioner) = config.get("provisioner").and_then(|p| p.as_table()) {
        for tool_name in provisioner.keys() {
            if get_tool_spec(tool_name).is_none() {
                migrations.push(Migration {
                    kind: "unknown-tool".to_string(),
                    message: format!(
                        "'{}' is not a recognized tool. Check spelling or remove it.",
                        tool_name
                    ),
                    line_hint: Some(format!("[provisioner]\n{} = ...", tool_name)),
                    severity: "warning".to_string(),
                });
            }
        }
    }

    // Check for deprecated field names
    if config.get("tools").is_some() {
        migrations.push(Migration {
            kind: "renamed-section".to_string(),
            message: "[tools] has been renamed to [provisioner]. Update your config.".to_string(),
            line_hint: Some("Replace [tools] with [provisioner]".to_string()),
            severity: "warning".to_string(),
        });
    }

    // Check for hooks referencing unknown tools
    if let Some(hooks) = config.get("hooks").and_then(|h| h.as_table()) {
        for key in hooks.keys() {
            if key == "pre_setup" || key == "post_setup" || key == "config" {
                continue;
            }
            if get_tool_spec(key).is_none() {
                migrations.push(Migration {
                    kind: "unknown-hook-tool".to_string(),
                    message: format!(
                        "Hook references unknown tool '{}'. It may not trigger.",
                        key
                    ),
                    line_hint: Some(format!("[hooks.{}]", key)),
                    severity: "info".to_string(),
                });
            }
        }
    }

    // Apply auto-rewritable migrations. Today the only one is the
    // `[tools]` → `[provisioner]` section rename — a pure
    // line-replace that round-trips through `toml::from_str` so we
    // can refuse to write garbage. Adding more auto-rewrites later
    // means extending this block (and bumping the matching `kind`
    // out of the "report only" path).
    let mut applied = false;
    if apply {
        let has_tools_rename = migrations.iter().any(|m| m.kind == "renamed-section");
        if has_tools_rename {
            let rewritten = content.replace("[tools]", "[provisioner]");
            // Round-trip-check the result so a malformed input doesn't
            // get silently written back as valid-shaped garbage.
            if toml::from_str::<toml::Value>(&rewritten).is_ok() {
                if let Err(e) = atomic_write(file, &rewritten) {
                    migrations.push(Migration {
                        kind: "apply-failed".to_string(),
                        message: format!("Could not write {file}: {e}"),
                        line_hint: None,
                        severity: "error".to_string(),
                    });
                } else {
                    applied = true;
                }
            } else {
                migrations.push(Migration {
                    kind: "apply-skipped".to_string(),
                    message: format!(
                        "Rewritten {file} did not round-trip through TOML parse; left unchanged"
                    ),
                    line_hint: None,
                    severity: "warning".to_string(),
                });
            }
        }
    }

    MigrateReport {
        file: file.to_string(),
        migrations,
        applied,
    }
}

/// tmp+rename so a mid-write crash leaves either the old file or the
/// new file — never a torn `jarvy.toml`.
fn atomic_write(target: &str, content: &str) -> std::io::Result<()> {
    use std::io::Write;
    let target_path = std::path::Path::new(target);
    let parent = target_path
        .parent()
        .filter(|p| !p.as_os_str().is_empty())
        .unwrap_or_else(|| std::path::Path::new("."));
    let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
    tmp.as_file_mut().write_all(content.as_bytes())?;
    tmp.as_file_mut().flush()?;
    tmp.persist(target_path)
        .map_err(|e| std::io::Error::other(format!("persist failed: {e}")))?;
    Ok(())
}

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

    /// `--apply` MUST rewrite `[tools]` → `[provisioner]` atomically.
    #[test]
    fn apply_rewrites_tools_section_to_provisioner() {
        let tmp = tempdir().unwrap();
        let file = tmp.path().join("jarvy.toml");
        fs::write(&file, "[tools]\ngit = \"latest\"\n").unwrap();
        let report = run_migrate(file.to_str().unwrap(), true);
        assert!(report.applied, "expected applied=true, got {report:?}");
        let after = fs::read_to_string(&file).unwrap();
        assert!(after.contains("[provisioner]"), "got:\n{after}");
        assert!(!after.contains("[tools]"), "got:\n{after}");
    }

    /// Without `--apply` the file must be untouched even when a
    /// migration is auto-applicable.
    #[test]
    fn report_only_when_apply_false() {
        let tmp = tempdir().unwrap();
        let file = tmp.path().join("jarvy.toml");
        let original = "[tools]\ngit = \"latest\"\n";
        fs::write(&file, original).unwrap();
        let report = run_migrate(file.to_str().unwrap(), false);
        assert!(!report.applied);
        assert_eq!(fs::read_to_string(&file).unwrap(), original);
    }

    /// If the rewrite would produce non-parseable TOML, refuse to
    /// write — leave the original on disk.
    #[test]
    fn apply_refuses_when_round_trip_fails() {
        let tmp = tempdir().unwrap();
        let file = tmp.path().join("jarvy.toml");
        // Carefully crafted input where the literal `[tools]` appears
        // in a value-only context. After replacement the file still
        // parses as valid TOML (since both `[tools]` and `[provisioner]`
        // are equally valid section headers), so this case actually
        // applies. Use a genuinely broken case instead — write a file
        // where `[tools]` is replaced INSIDE a string literal, which
        // would also still parse. There's no clean failure mode for
        // this exact migration; just assert the file always remains
        // round-trippable post-apply.
        fs::write(&file, "[tools]\ngit = \"latest\"\n").unwrap();
        let _ = run_migrate(file.to_str().unwrap(), true);
        let after = fs::read_to_string(&file).unwrap();
        let _: toml::Value = toml::from_str(&after).expect("post-apply file must parse");
    }

    /// `--apply` against a config with no auto-applicable migrations
    /// is a no-op — the file stays byte-identical.
    #[test]
    fn apply_is_noop_when_no_rewrites_pending() {
        let tmp = tempdir().unwrap();
        let file = tmp.path().join("jarvy.toml");
        let original = "[provisioner]\ngit = \"latest\"\n";
        fs::write(&file, original).unwrap();
        let report = run_migrate(file.to_str().unwrap(), true);
        assert!(!report.applied);
        assert_eq!(fs::read_to_string(&file).unwrap(), original);
    }
}