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
11mod connection;
12mod legacy;
13mod metadata;
14mod sql;
15
16const MACHINE_DB: &str = "machine.db";
17
18/// Path to the machine registry db, or `None` if `KAIZEN_HOME` / `HOME` is unset.
19pub fn db_path() -> Option<PathBuf> {
20    kaizen_dir().map(|d| d.join(MACHINE_DB))
21}
22
23fn db_path_for_write(workspace: &Path) -> Result<Option<PathBuf>> {
24    if kaizen_dir().is_none() {
25        return Ok(None);
26    }
27    crate::core::home_paths::sqlite_file_for_write(workspace, MACHINE_DB).map(Some)
28}
29
30fn now_ms() -> i64 {
31    SystemTime::now()
32        .duration_since(UNIX_EPOCH)
33        .unwrap_or_default()
34        .as_millis() as i64
35}
36
37fn name_for_path(path: &Path) -> String {
38    path.file_name()
39        .and_then(|n| n.to_str())
40        .map(str::to_string)
41        .unwrap_or_default()
42}
43
44fn open_conn_write(workspace: &Path) -> Result<Option<Connection>> {
45    connection::open_write(workspace)
46}
47
48fn open_conn_read() -> Result<Option<Connection>> {
49    connection::open_read()
50}
51
52fn with_write<F>(workspace: &Path, f: F) -> Result<()>
53where
54    F: FnOnce(&Connection) -> Result<()>,
55{
56    let Some(conn) = open_conn_write(workspace)? else {
57        return Ok(());
58    };
59    legacy::migrate(&conn)?;
60    f(&conn)
61}
62
63/// Upsert a workspace seen from [`resolve`](crate::core::workspace::resolve).
64pub fn upsert_from_resolve(path: &Path) -> Result<()> {
65    with_write(path, |conn| upsert_seen(conn, path))
66}
67
68fn upsert_seen(conn: &Connection, path: &Path) -> Result<()> {
69    let c = canonical(path);
70    let t = now_ms();
71    let name = name_for_path(&c);
72    let p = c.to_string_lossy();
73    conn.execute(sql::UPSERT_SEEN, params![p.as_ref(), &name, t, t])
74        .context("machine registry upsert from resolve")?;
75    Ok(())
76}
77
78/// Record a successful `kaizen init` (increments `init_count`, optional git + version).
79pub fn record_init(path: &Path) -> Result<()> {
80    with_write(path, |conn| insert_init(conn, path))
81}
82
83fn insert_init(conn: &Connection, path: &Path) -> Result<()> {
84    let c = canonical(path);
85    let t = now_ms();
86    let name = name_for_path(&c);
87    let p = c.to_string_lossy();
88    let origin = metadata::git_remote_origin(&c);
89    let values = params![
90        p.as_ref(),
91        &name,
92        t,
93        t,
94        t,
95        origin.as_deref(),
96        env!("CARGO_PKG_VERSION")
97    ];
98    conn.execute(sql::RECORD_INIT, values)
99        .context("machine registry record init")?;
100    Ok(())
101}
102
103/// All known workspace paths from the machine registry.
104pub fn list_paths() -> Result<Vec<PathBuf>> {
105    Ok(list_paths_including_missing()?
106        .into_iter()
107        .filter(|path| path.is_dir())
108        .collect())
109}
110
111/// All registry rows, including workspace paths that no longer exist.
112pub fn list_paths_including_missing() -> Result<Vec<PathBuf>> {
113    let mut paths = legacy::read_paths();
114    if let Some(conn) = open_conn_read()? {
115        extend_unique(&mut paths, query_paths(&conn)?);
116    }
117    Ok(paths)
118}
119
120fn extend_unique(paths: &mut Vec<PathBuf>, additions: impl IntoIterator<Item = PathBuf>) {
121    additions.into_iter().for_each(|path| {
122        if !paths.contains(&path) {
123            paths.push(path);
124        }
125    });
126}
127
128fn query_paths(conn: &Connection) -> Result<Vec<PathBuf>> {
129    let mut stmt = conn
130        .prepare(sql::LIST_PATHS)
131        .context("machine registry list paths")?;
132    let rows = stmt
133        .query_map([], |row| row.get::<_, String>(0).map(PathBuf::from))
134        .context("query machine registry")?;
135    Ok(rows.collect::<rusqlite::Result<_>>()?)
136}
137
138/// `true` if this path is a row in the machine registry (compared after canonicalize).
139pub fn is_registered(path: &Path) -> bool {
140    let canonical = canonical(path);
141    if legacy::read_paths().contains(&canonical) {
142        return true;
143    }
144    let Some(conn) = open_conn_read().ok().flatten() else {
145        return false;
146    };
147    registered(&conn, &canonical)
148}
149
150fn registered(conn: &Connection, path: &Path) -> bool {
151    let c = canonical(path);
152    let p = c.to_string_lossy();
153    conn.query_row(sql::IS_REGISTERED, [p.as_ref()], |_| Ok(()))
154        .is_ok()
155}
156
157/// Read machine registry status without creating machine state.
158pub fn status() -> Result<Option<(PathBuf, usize)>> {
159    let Some(path) = db_path() else {
160        return Ok(None);
161    };
162    let legacy = legacy::read_paths();
163    let conn = open_conn_read()?;
164    Ok(Some((path, total_project_count(conn.as_ref(), &legacy))))
165}
166
167fn total_project_count(conn: Option<&Connection>, legacy: &[PathBuf]) -> usize {
168    match conn {
169        Some(conn) => project_count(conn) + legacy.iter().filter(|p| !registered(conn, p)).count(),
170        None => legacy.len(),
171    }
172}
173
174fn project_count(conn: &Connection) -> usize {
175    conn.query_row(sql::PROJECT_COUNT, [], |row| row.get::<_, i64>(0))
176        .unwrap_or(0) as usize
177}