1use 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
26pub 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
62fn 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
107pub 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
128pub 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
172pub 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
194pub 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
212pub 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}