algocline-app 0.22.0

algocline application layer — execution orchestration, package management
Documentation
//! Installed-packages manifest (`~/.algocline/installed.json`).
//!
//! Records package name, version, source, and install/update timestamps.
//! Written on `pkg_install` success, pruned on `pkg_remove`.
//! Read by `pkg_list` to display version tracking info.

use std::collections::BTreeMap;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

/// Per-package record in the manifest.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub(crate) struct ManifestEntry {
    /// Package version from `M.meta.version` (if available).
    pub version: Option<String>,
    /// How the package was installed (git URL, local path, or "bundled").
    pub source: String,
    /// ISO 8601 timestamp of first install.
    pub installed_at: String,
    /// ISO 8601 timestamp of last update (same as installed_at if never updated).
    pub updated_at: String,
}

/// Top-level manifest structure.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub(crate) struct Manifest {
    pub packages: BTreeMap<String, ManifestEntry>,
}

// ─── Paths ─────────────────────────────────────────────────────

fn manifest_path() -> Result<PathBuf, String> {
    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
    Ok(home.join(".algocline").join("installed.json"))
}

/// Path to the advisory lock companion file for the manifest.
fn manifest_lock_path() -> Result<PathBuf, String> {
    let home = dirs::home_dir().ok_or("Cannot determine home directory")?;
    Ok(home.join(".algocline").join("installed.json.lock"))
}

/// Execute `f` while holding an exclusive `flock` on the manifest's
/// companion lock file.
///
/// `record_install`, `record_install_batch`, and `record_remove` all
/// perform an unguarded read-modify-write sequence on `installed.json`.
/// When two `pkg_install` calls overlap (e.g. the e2e suite running
/// multiple `test_*` in-process tests against the same `$HOME`), one
/// writer can load the manifest, the other completes its own
/// load-modify-write, and the first writer then overwrites with its
/// now-stale copy — losing entries.
///
/// The lock is released when the `File` is dropped. Dropping happens on
/// every exit from this function, including a panic in `f`: stack
/// unwinding runs `File`'s destructor, which calls `close(2)` and thus
/// releases the advisory `flock`. The explicit `drop(file)` after `f`
/// is just ordering insurance for the success path.
fn with_manifest_lock<F, R>(f: F) -> Result<R, String>
where
    F: FnOnce() -> Result<R, String>,
{
    let lock_path = manifest_lock_path()?;
    crate::service::lock::with_exclusive_lock(&lock_path, f)
}

// ─── Read / Write ──────────────────────────────────────────────

/// Load the manifest from disk. Returns empty manifest if file is missing.
pub(crate) fn load_manifest() -> Result<Manifest, String> {
    let path = manifest_path()?;
    if !path.exists() {
        return Ok(Manifest::default());
    }
    let content =
        std::fs::read_to_string(&path).map_err(|e| format!("Failed to read manifest: {e}"))?;
    serde_json::from_str(&content).map_err(|e| format!("Failed to parse manifest: {e}"))
}

/// Save the manifest to disk (pretty-printed for human readability).
///
/// Writes atomically: serialize into `installed.json.tmp`, then `rename` onto
/// `installed.json`. POSIX `rename(2)` is atomic within the same filesystem,
/// so an unguarded reader — e.g. `load_manifest` in `pkg_list` or `pkg_repair`,
/// which does not take `with_manifest_lock` — never observes a partially
/// written JSON document. Without the temp-and-rename dance, a concurrent
/// reader could see a truncated file between `open(TRUNC)` and the final
/// `write`, producing a spurious "Failed to parse manifest" error.
fn save_manifest(manifest: &Manifest) -> Result<(), String> {
    let path = manifest_path()?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| format!("Failed to create manifest dir: {e}"))?;
    }
    let content = serde_json::to_string_pretty(manifest)
        .map_err(|e| format!("Failed to serialize manifest: {e}"))?;

    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, &content)
        .map_err(|e| format!("Failed to write manifest temp file {}: {e}", tmp.display()))?;
    std::fs::rename(&tmp, &path).map_err(|e| {
        // Best-effort cleanup of the stale temp file on rename failure.
        let _ = std::fs::remove_file(&tmp);
        format!(
            "Failed to atomically rename manifest temp onto {}: {e}",
            path.display()
        )
    })
}

// ─── Operations ────────────────────────────────────────────────

pub(crate) fn now_iso8601() -> String {
    // Use SystemTime for a simple UTC timestamp without extra dependencies.
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    // Manual formatting: YYYY-MM-DDTHH:MM:SSZ
    let s = secs as i64;
    let days = s / 86400;
    let time_of_day = s % 86400;
    let h = time_of_day / 3600;
    let m = (time_of_day % 3600) / 60;
    let sec = time_of_day % 60;

    // Days since epoch to Y-M-D (simplified Gregorian)
    let (y, mo, d) = days_to_ymd(days);
    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{sec:02}Z")
}

/// Convert days since 1970-01-01 to (year, month, day).
fn days_to_ymd(days: i64) -> (i64, i64, i64) {
    // Algorithm from Howard Hinnant's civil_from_days
    let z = days + 719468;
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
    let doe = z - era * 146097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y, m, d)
}

/// Record a successful install/update in the manifest.
///
/// - If the package already exists:
///   - `version` is overwritten **only when the caller provides `Some(_)`**;
///     `None` preserves whatever version was stored previously. Rationale:
///     the git-clone single-pkg path in `pkg_install` always passes `None`
///     here (it fetches the version later via `update_project_files_for_install`
///     for `alc.lock`, and does not write it back into this manifest).
///     Blindly clobbering with `None` would erase the version displayed by
///     `pkg_list` on every re-install.
///   - `source` and `updated_at` are always refreshed.
/// - If new, sets both `installed_at` and `updated_at` to now, and records
///   `version` verbatim (may be `None`).
///
/// `version` is extracted from the package's `M.meta.version` field if provided.
pub(crate) fn record_install(
    name: &str,
    version: Option<&str>,
    source: &str,
) -> Result<(), String> {
    with_manifest_lock(|| {
        let mut manifest = load_manifest()?;
        let now = now_iso8601();

        manifest
            .packages
            .entry(name.to_string())
            .and_modify(|e| {
                if let Some(v) = version {
                    e.version = Some(v.to_string());
                }
                e.source = source.to_string();
                e.updated_at = now.clone();
            })
            .or_insert_with(|| ManifestEntry {
                version: version.map(String::from),
                source: source.to_string(),
                installed_at: now.clone(),
                updated_at: now,
            });

        save_manifest(&manifest)
    })
}

/// Record a batch of installs (e.g. collection mode).
pub(crate) fn record_install_batch(names: &[String], source: &str) -> Result<(), String> {
    if names.is_empty() {
        return Ok(());
    }
    with_manifest_lock(|| {
        let mut manifest = load_manifest()?;
        let now = now_iso8601();

        for name in names {
            manifest
                .packages
                .entry(name.clone())
                .and_modify(|e| {
                    e.source = source.to_string();
                    e.updated_at = now.clone();
                })
                .or_insert_with(|| ManifestEntry {
                    version: None, // batch installs don't have per-package version info readily
                    source: source.to_string(),
                    installed_at: now.clone(),
                    updated_at: now.clone(),
                });
        }

        save_manifest(&manifest)
    })
}

/// Remove a package from the manifest.
#[allow(dead_code)]
pub(crate) fn record_remove(name: &str) -> Result<(), String> {
    with_manifest_lock(|| {
        let mut manifest = load_manifest()?;
        manifest.packages.remove(name);
        save_manifest(&manifest)
    })
}

/// Load manifest for test with custom path.
#[cfg(test)]
pub(crate) fn load_manifest_from(path: &std::path::Path) -> Result<Manifest, String> {
    if !path.exists() {
        return Ok(Manifest::default());
    }
    let content =
        std::fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {e}"))?;
    serde_json::from_str(&content).map_err(|e| format!("Failed to parse manifest: {e}"))
}

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

    #[test]
    fn days_to_ymd_epoch() {
        assert_eq!(days_to_ymd(0), (1970, 1, 1));
    }

    #[test]
    fn days_to_ymd_known_date() {
        // 2024-01-01 = day 19723
        assert_eq!(days_to_ymd(19723), (2024, 1, 1));
    }

    #[test]
    fn manifest_roundtrip() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("installed.json");

        let mut manifest = Manifest::default();
        manifest.packages.insert(
            "cot".to_string(),
            ManifestEntry {
                version: Some("0.1.0".to_string()),
                source: "https://github.com/ynishi/algocline-bundled-packages".to_string(),
                installed_at: "2024-01-01T00:00:00Z".to_string(),
                updated_at: "2024-01-01T00:00:00Z".to_string(),
            },
        );

        let content = serde_json::to_string_pretty(&manifest).unwrap();
        std::fs::write(&path, &content).unwrap();

        let loaded = load_manifest_from(&path).unwrap();
        assert_eq!(loaded, manifest);
    }

    #[test]
    fn manifest_empty_file_missing() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("nonexistent.json");
        let loaded = load_manifest_from(&path).unwrap();
        assert!(loaded.packages.is_empty());
    }

    #[test]
    fn record_install_none_preserves_existing_version() {
        // Regression: prior to `and_modify { e.version = version.map(..) }` →
        // conditional assignment, re-installing a git-cloned single pkg
        // (which passes `version = None`) silently erased the stored version
        // displayed by `pkg_list`. Guarantee: `None` preserves; `Some` overwrites.
        let _fake_home = super::super::test_support::FakeHome::new();

        // Seed: insert an entry with a known version.
        record_install(
            "cot",
            Some("0.1.0"),
            "https://github.com/ynishi/algocline-bundled-packages",
        )
        .unwrap();
        let before = load_manifest().unwrap();
        assert_eq!(
            before.packages.get("cot").unwrap().version.as_deref(),
            Some("0.1.0")
        );

        // Re-install with `None` — should keep "0.1.0", not clobber.
        record_install(
            "cot",
            None,
            "https://github.com/ynishi/algocline-bundled-packages",
        )
        .unwrap();
        let after_none = load_manifest().unwrap();
        assert_eq!(
            after_none.packages.get("cot").unwrap().version.as_deref(),
            Some("0.1.0"),
            "version=None must preserve existing version"
        );

        // Re-install with `Some("0.2.0")` — should overwrite.
        record_install(
            "cot",
            Some("0.2.0"),
            "https://github.com/ynishi/algocline-bundled-packages",
        )
        .unwrap();
        let after_some = load_manifest().unwrap();
        assert_eq!(
            after_some.packages.get("cot").unwrap().version.as_deref(),
            Some("0.2.0"),
            "version=Some(_) must overwrite"
        );
    }
}