magi-code 0.82.1

Repository-aware CLI coding agent for terminal work
Documentation
use super::{McPaths, settings::SettingsScope, settings_storage};

const SEEN_KEY: &str = "welcome_release_notes_seen";

const PROGRESS_KEY: &str = "welcome_release_notes_progress";

#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct UnreadStart {
    version: String,
    inclusive: bool,
}

impl UnreadStart {
    pub(crate) fn contains(&self, version: &str, current: &str) -> bool {
        let (Ok(version), Ok(current), Ok(start)) = (
            semver::Version::parse(version),
            semver::Version::parse(current),
            semver::Version::parse(&self.version),
        ) else {
            return false;
        };
        version <= current && (version > start || (self.inclusive && version == start))
    }
}

#[derive(serde::Serialize, serde::Deserialize)]
struct Progress {
    unread_from: UnreadStart,
}

pub(crate) fn record_launch(paths: &McPaths, current: &str) -> anyhow::Result<UnreadStart> {
    let current_version = semver::Version::parse(current)?;
    let mut unread = None;
    settings_storage::update_settings_json(paths, SettingsScope::Global, |raw| {
        let seen = raw
            .get(SEEN_KEY)
            .map(|value| {
                value
                    .as_object()
                    .ok_or_else(|| anyhow::anyhow!("{SEEN_KEY} must be an object"))
            })
            .transpose()?;
        let progress = if let Some(value) = raw.get(PROGRESS_KEY) {
            let progress: Progress = serde_json::from_value(value.clone())?;
            semver::Version::parse(&progress.unread_from.version)?;
            progress
        } else {
            let baseline = seen
                .into_iter()
                .flat_map(|seen| seen.iter())
                .filter(|(_, value)| value.as_bool() == Some(true))
                .filter_map(|(version, _)| semver::Version::parse(version).ok())
                .filter(|version| version <= &current_version)
                .max();
            Progress {
                unread_from: UnreadStart {
                    inclusive: baseline.is_none(),
                    version: baseline
                        .map_or_else(|| current.to_owned(), |version| version.to_string()),
                },
            }
        };
        unread = Some(progress.unread_from.clone());
        raw[PROGRESS_KEY] = serde_json::to_value(progress)?;
        Ok(())
    })?;
    Ok(unread.expect("successful settings update selects unread range"))
}

#[cfg(test)]
pub(crate) fn has_seen(paths: &McPaths, version: &str) -> anyhow::Result<bool> {
    let raw = settings_storage::read_settings_json_or_empty(&paths.settings_file)?;
    Ok(raw
        .get(SEEN_KEY)
        .and_then(|seen| seen.get(version))
        .and_then(|v| v.as_bool())
        == Some(true))
}

// Acknowledgement advances only through the running version, never through a newer launch.
pub(crate) fn mark_seen(paths: &McPaths, version: &str) -> anyhow::Result<()> {
    settings_storage::update_settings_json(paths, SettingsScope::Global, |raw| {
        if let Some(value) = raw.get(PROGRESS_KEY) {
            let mut progress: Progress = serde_json::from_value(value.clone())?;
            if semver::Version::parse(version)?
                >= semver::Version::parse(&progress.unread_from.version)?
            {
                progress.unread_from = UnreadStart {
                    version: version.to_owned(),
                    inclusive: false,
                };
                raw[PROGRESS_KEY] = serde_json::to_value(progress)?;
            }
        }
        let seen = raw
            .as_object_mut()
            .expect("storage validates object")
            .entry(SEEN_KEY)
            .or_insert_with(|| serde_json::json!({}));
        let seen = seen
            .as_object_mut()
            .ok_or_else(|| anyhow::anyhow!("{SEEN_KEY} must be an object"))?;
        seen.insert(version.to_owned(), serde_json::Value::Bool(true));
        Ok(())
    })?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn release_notes_fresh_install_and_migration_ignore_future_seen_versions() {
        let temp = tempfile::tempdir().unwrap();
        let paths = McPaths::from_root(temp.path().to_owned());
        mark_seen(&paths, "9.0.0").unwrap();
        let unread = record_launch(&paths, "1.2.10").unwrap();
        assert!(unread.contains("1.2.10", "1.2.10"));
        assert!(!unread.contains("1.2.9", "1.2.10"));
        assert!(!unread.contains("9.0.0", "1.2.10"));
        let downgrade = record_launch(&paths, "1.2.8").unwrap();
        assert!(!downgrade.contains("1.2.8", "1.2.8"));
        mark_seen(&paths, "1.2.8").unwrap();
        assert!(
            record_launch(&paths, "1.2.10")
                .unwrap()
                .contains("1.2.10", "1.2.10")
        );
    }

    #[test]
    fn release_notes_migration_uses_highest_numeric_seen_baseline() {
        let temp = tempfile::tempdir().unwrap();
        let paths = McPaths::from_root(temp.path().to_owned());
        for version in ["1.2.9", "1.2.10", "9.0.0"] {
            mark_seen(&paths, version).unwrap();
        }
        let unread = record_launch(&paths, "1.2.12").unwrap();
        assert!(!unread.contains("1.2.10", "1.2.12"));
        assert!(unread.contains("1.2.11", "1.2.12"));
        assert!(unread.contains("1.2.12", "1.2.12"));
    }

    #[test]
    fn release_notes_seen_is_global_versioned_and_preserves_unknown_settings() {
        let temp = tempfile::tempdir().unwrap();
        let paths = McPaths::from_root_and_project_dir(
            temp.path().join("home"),
            temp.path().join("project"),
        );
        std::fs::create_dir_all(&paths.root).unwrap();
        std::fs::write(
            &paths.settings_file,
            r#"{"schema_version":2,"future":{"keep":42}}"#,
        )
        .unwrap();
        assert!(!has_seen(&paths, "1.2.3").unwrap());
        mark_seen(&paths, "1.2.3").unwrap();
        mark_seen(&paths, "1.2.4").unwrap();
        assert!(has_seen(&paths, "1.2.3").unwrap());
        assert!(!has_seen(&paths, "1.2.3-beta.1").unwrap());
        let other =
            McPaths::from_root_and_project_dir(paths.root.clone(), temp.path().join("other"));
        assert!(has_seen(&other, "1.2.4").unwrap());
        let raw = settings_storage::read_settings_json_or_empty(&paths.settings_file).unwrap();
        assert_eq!(raw["future"]["keep"], 42);
        assert!(!paths.project_settings_file.exists());
    }

    #[test]
    fn release_notes_seen_rejects_corrupt_storage_without_overwriting() {
        let temp = tempfile::tempdir().unwrap();
        let paths = McPaths::from_root(temp.path().to_owned());
        for invalid in [
            "not json",
            r#"{"welcome_release_notes_seen":false}"#,
            r#"{"welcome_release_notes_progress":false}"#,
        ] {
            std::fs::write(&paths.settings_file, invalid).unwrap();
            assert!(mark_seen(&paths, "1.0.0").is_err());
            assert!(record_launch(&paths, "1.0.0").is_err());
            assert_eq!(
                std::fs::read_to_string(&paths.settings_file).unwrap(),
                invalid
            );
        }
    }

    #[cfg(unix)]
    #[test]
    fn release_notes_seen_rejects_symlink() {
        let temp = tempfile::tempdir().unwrap();
        let paths = McPaths::from_root(temp.path().to_owned());
        let target = temp.path().join("target.json");
        std::fs::write(&target, "{}").unwrap();
        std::os::unix::fs::symlink(&target, &paths.settings_file).unwrap();
        assert!(has_seen(&paths, "1.0.0").is_err());
        assert!(mark_seen(&paths, "1.0.0").is_err());
        assert!(record_launch(&paths, "1.0.0").is_err());
        assert_eq!(std::fs::read_to_string(target).unwrap(), "{}");
    }
}