Skip to main content

kaizen/core/
machine_registry.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Machine-local SQLite registry (`$KAIZEN_HOME/machine.db`) — known workspace roots, init history.
3
4use anyhow::{Context, Result};
5use rusqlite::{Connection, params};
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::core::paths::{canonical, kaizen_dir};
10
11const MACHINE_DB: &str = "machine.db";
12const LEGACY_WORKSPACES_JSON: &str = "workspaces.json";
13
14const MIGRATIONS: &[&str] = &["CREATE TABLE IF NOT EXISTS projects (
15        path TEXT PRIMARY KEY,
16        name TEXT NOT NULL,
17        first_seen_ms INTEGER NOT NULL,
18        last_seen_ms INTEGER NOT NULL,
19        last_init_ms INTEGER,
20        init_count INTEGER NOT NULL DEFAULT 0,
21        git_remote_origin TEXT,
22        kaizen_version_at_init TEXT,
23        meta TEXT
24    )"];
25
26/// Path to the machine registry db, or `None` if `KAIZEN_HOME` / `HOME` is unset.
27pub fn db_path() -> Option<PathBuf> {
28    kaizen_dir().map(|d| d.join(MACHINE_DB))
29}
30
31fn now_ms() -> i64 {
32    SystemTime::now()
33        .duration_since(UNIX_EPOCH)
34        .unwrap_or_default()
35        .as_millis() as i64
36}
37
38fn name_for_path(path: &Path) -> String {
39    path.file_name()
40        .and_then(|n| n.to_str())
41        .map(str::to_string)
42        .unwrap_or_default()
43}
44
45fn open_conn_write() -> Result<Option<Connection>> {
46    let Some(path) = db_path() else {
47        return Ok(None);
48    };
49    if let Some(parent) = path.parent() {
50        std::fs::create_dir_all(parent)?;
51    }
52    let conn = Connection::open(&path)
53        .with_context(|| format!("open machine registry: {}", path.display()))?;
54    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?;
55    for sql in MIGRATIONS {
56        conn.execute_batch(sql)
57            .with_context(|| format!("machine registry migration: {sql}"))?;
58    }
59    Ok(Some(conn))
60}
61
62/// Import legacy `workspaces.json` if present, then rename it.
63fn migrate_legacy_workspaces_json(conn: &Connection) -> Result<()> {
64    let Some(home) = kaizen_dir() else {
65        return Ok(());
66    };
67    let legacy = home.join(LEGACY_WORKSPACES_JSON);
68    if !legacy.exists() {
69        return Ok(());
70    }
71    let text = std::fs::read_to_string(&legacy).unwrap_or_default();
72    let rows: Vec<String> = serde_json::from_str(&text).unwrap_or_default();
73    let t = now_ms();
74    for s in rows {
75        let p = PathBuf::from(&s);
76        if p.exists() {
77            let c = canonical(&p);
78            let name = name_for_path(&c);
79            let _ = conn.execute(
80                "INSERT INTO projects (path, name, first_seen_ms, last_seen_ms, last_init_ms, init_count, git_remote_origin, kaizen_version_at_init, meta)
81                 VALUES (?1, ?2, ?3, ?4, NULL, 0, NULL, NULL, NULL)
82                 ON CONFLICT(path) DO UPDATE SET
83                   last_seen_ms = MAX(projects.last_seen_ms, excluded.last_seen_ms),
84                   name = excluded.name",
85                params![c.to_string_lossy().as_ref(), &name, t, t,],
86            );
87        }
88    }
89    let migrated = home.join("workspaces.json.migrated");
90    if std::fs::rename(&legacy, &migrated).is_err() {
91        let _ = std::fs::remove_file(&legacy);
92    }
93    Ok(())
94}
95
96fn with_write<F>(f: F) -> Result<()>
97where
98    F: FnOnce(&Connection) -> Result<()>,
99{
100    let Some(conn) = open_conn_write()? else {
101        return Ok(());
102    };
103    migrate_legacy_workspaces_json(&conn)?;
104    f(&conn)
105}
106
107/// Upsert a workspace seen from [`resolve`](crate::core::workspace::resolve).
108pub fn upsert_from_resolve(path: &Path) -> Result<()> {
109    with_write(|conn| {
110        let c = canonical(path);
111        let t = now_ms();
112        let name = name_for_path(&c);
113        let p = c.to_string_lossy();
114        conn.execute(
115            "INSERT INTO projects (path, name, first_seen_ms, last_seen_ms, last_init_ms, init_count, git_remote_origin, kaizen_version_at_init, meta)
116             VALUES (?1, ?2, ?3, ?4, NULL, 0, NULL, NULL, NULL)
117             ON CONFLICT(path) DO UPDATE SET
118               name = excluded.name,
119               last_seen_ms = MAX(projects.last_seen_ms, excluded.last_seen_ms),
120               first_seen_ms = projects.first_seen_ms",
121            params![p.as_ref(), &name, t, t],
122        )
123        .context("machine registry upsert from resolve")?;
124        Ok(())
125    })
126}
127
128/// Record a successful `kaizen init` (increments `init_count`, optional git + version).
129pub fn record_init(path: &Path) -> Result<()> {
130    with_write(|conn| {
131        let c = canonical(path);
132        let t = now_ms();
133        let name = name_for_path(&c);
134        let p = c.to_string_lossy();
135        let ver = env!("CARGO_PKG_VERSION");
136        let origin = git_remote_origin(&c);
137        let origin_ref = origin.as_deref();
138        conn.execute(
139            "INSERT INTO projects (path, name, first_seen_ms, last_seen_ms, last_init_ms, init_count, git_remote_origin, kaizen_version_at_init, meta)
140             VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7, NULL)
141             ON CONFLICT(path) DO UPDATE SET
142               name = excluded.name,
143               last_seen_ms = MAX(projects.last_seen_ms, excluded.last_seen_ms),
144               last_init_ms = excluded.last_init_ms,
145               init_count = projects.init_count + 1,
146               git_remote_origin = COALESCE(excluded.git_remote_origin, projects.git_remote_origin),
147               kaizen_version_at_init = excluded.kaizen_version_at_init,
148               first_seen_ms = projects.first_seen_ms",
149            params![p.as_ref(), &name, t, t, t, origin_ref, ver],
150        )
151        .context("machine registry record init")?;
152        Ok(())
153    })
154}
155
156fn git_remote_origin(repo: &Path) -> Option<String> {
157    let out = std::process::Command::new("git")
158        .arg("-C")
159        .arg(repo)
160        .args(["remote", "get-url", "origin"])
161        .output()
162        .ok()?;
163    if out.status.success() {
164        return String::from_utf8(out.stdout)
165            .ok()
166            .map(|s| s.trim().to_string())
167            .filter(|s| !s.is_empty());
168    }
169    None
170}
171
172/// All known workspace paths from the machine registry.
173pub fn list_paths() -> Result<Vec<PathBuf>> {
174    let Some(conn) = open_conn_write()? else {
175        return Ok(Vec::new());
176    };
177    migrate_legacy_workspaces_json(&conn)?;
178    let mut stmt = conn
179        .prepare("SELECT path FROM projects ORDER BY last_seen_ms DESC")
180        .context("machine registry list paths")?;
181    let rows = stmt
182        .query_map([], |r| {
183            let s: String = r.get(0)?;
184            Ok(PathBuf::from(s))
185        })
186        .context("query machine registry")?;
187    let mut out = Vec::new();
188    for row in rows {
189        out.push(row?);
190    }
191    Ok(out)
192}
193
194/// `true` if this path is a row in the machine registry (compared after canonicalize).
195pub fn is_registered(path: &Path) -> bool {
196    let Some(conn) = open_conn_write().ok().flatten() else {
197        return false;
198    };
199    if migrate_legacy_workspaces_json(&conn).is_err() {
200        return false;
201    }
202    let c = canonical(path);
203    let p = c.to_string_lossy();
204    conn.query_row(
205        "SELECT 1 FROM projects WHERE path = ?1",
206        [p.as_ref()],
207        |_| Ok(()),
208    )
209    .is_ok()
210}
211
212/// Open machine registry (read/write), run migrations, return project count, or `None` if no kaizen home.
213pub fn status() -> Result<Option<(PathBuf, usize)>> {
214    let Some(path) = db_path() else {
215        return Ok(None);
216    };
217    let Some(conn) = open_conn_write()? else {
218        return Ok(None);
219    };
220    migrate_legacy_workspaces_json(&conn)?;
221    let n: i64 = conn
222        .query_row("SELECT COUNT(*) FROM projects", [], |r| r.get(0))
223        .unwrap_or(0);
224    Ok(Some((path, n as usize)))
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::core::paths::test_lock;
231
232    #[test]
233    fn upsert_and_list() {
234        let _g = test_lock::global().lock().unwrap();
235        let tmp = tempfile::tempdir().unwrap();
236        let home = tmp.path().join(".kaizen");
237        std::fs::create_dir_all(&home).unwrap();
238        unsafe { std::env::set_var("KAIZEN_HOME", &home) };
239        let ws = tmp.path().join("r");
240        std::fs::create_dir_all(&ws).unwrap();
241        let ws = std::fs::canonicalize(&ws).unwrap();
242        upsert_from_resolve(&ws).unwrap();
243        let paths = list_paths().unwrap();
244        assert_eq!(paths, vec![ws]);
245        assert!(is_registered(&paths[0]));
246        unsafe { std::env::remove_var("KAIZEN_HOME") };
247    }
248}