Skip to main content

gobby_code/
project.rs

1//! Project identity resolution for gcode standalone mode.
2//!
3//! Resolution order: .gobby/project.json (gobby) > .gobby/gcode.json (standalone) > generate on-the-fly.
4//! gcode never writes to project.json — that's gobby's file.
5
6use std::path::{Path, PathBuf};
7
8use anyhow::Context as _;
9use gobby_core::project::read_project_id;
10use uuid::Uuid;
11
12use crate::models::CODE_INDEX_UUID_NAMESPACE;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct IsolationMarker {
16    pub parent_project_path: Option<String>,
17    pub parent_project_id: Option<String>,
18}
19
20/// Read project ID from `.gobby/gcode.json`.
21pub fn read_gcode_json(project_root: &Path) -> anyhow::Result<String> {
22    let path = project_root.join(".gobby").join("gcode.json");
23    let contents = std::fs::read_to_string(&path)
24        .with_context(|| format!("failed to read {}", path.display()))?;
25    let json: serde_json::Value = serde_json::from_str(&contents)?;
26    json.get("id")
27        .and_then(|v| v.as_str())
28        .map(String::from)
29        .context("'id' field not found in .gobby/gcode.json")
30}
31
32/// Generate a deterministic code-index ID from the canonical project root path.
33/// Uses UUID5 with the same namespace as symbol IDs — key format (bare path)
34/// differs from symbol keys so there's no collision risk.
35pub fn code_index_id_for_root(root: &Path) -> String {
36    let canonical = root
37        .canonicalize()
38        .unwrap_or_else(|_| absolute_fallback(root));
39    Uuid::new_v5(
40        &CODE_INDEX_UUID_NAMESPACE,
41        canonical.to_string_lossy().as_bytes(),
42    )
43    .to_string()
44}
45
46/// Backward-compatible name for standalone gcode identity generation.
47pub fn generate_project_id(project_root: &Path) -> String {
48    code_index_id_for_root(project_root)
49}
50
51/// Read the isolated-root marker from `.gobby/project.json`, if present.
52pub fn read_isolation_marker(project_root: &Path) -> Option<IsolationMarker> {
53    let path = project_root.join(".gobby").join("project.json");
54    let contents = std::fs::read_to_string(path).ok()?;
55    let json: serde_json::Value = serde_json::from_str(&contents).ok()?;
56    let parent_project_path = json
57        .get("parent_project_path")
58        .and_then(|v| v.as_str())
59        .filter(|s| !s.is_empty())
60        .map(ToOwned::to_owned);
61    let parent_project_id = json
62        .get("parent_project_id")
63        .and_then(|v| v.as_str())
64        .filter(|s| !s.is_empty())
65        .map(ToOwned::to_owned);
66
67    if parent_project_path.is_some() || parent_project_id.is_some() {
68        Some(IsolationMarker {
69            parent_project_path,
70            parent_project_id,
71        })
72    } else {
73        None
74    }
75}
76
77/// Ensure a gcode identity file exists. Non-destructive:
78/// - If `project.json` exists, reads its ID (gobby owns this project)
79/// - If `gcode.json` exists, reads its ID
80/// - If neither exists, creates `gcode.json`
81///
82/// Returns `(project_id, was_created)`.
83pub fn ensure_gcode_json(project_root: &Path) -> anyhow::Result<(String, bool)> {
84    // Gobby's file takes priority
85    let project_json = project_root.join(".gobby").join("project.json");
86    if project_json.exists() {
87        return Ok((read_project_id(project_root)?, false));
88    }
89
90    // Already initialized by gcode
91    let gcode_json = project_root.join(".gobby").join("gcode.json");
92    if gcode_json.exists() {
93        return Ok((read_gcode_json(project_root)?, false));
94    }
95
96    // Create .gobby/ directory and gcode.json
97    let gobby_dir = project_root.join(".gobby");
98    std::fs::create_dir_all(&gobby_dir)
99        .with_context(|| format!("failed to create {}", gobby_dir.display()))?;
100
101    let project_id = generate_project_id(project_root);
102    let project_name = project_root
103        .file_name()
104        .map(|n| n.to_string_lossy().to_string())
105        .unwrap_or_else(|| "unknown".to_string());
106
107    let created_at = now_iso8601();
108
109    let content = serde_json::json!({
110        "id": project_id,
111        "name": project_name,
112        "created_at": created_at
113    });
114
115    let json_str = serde_json::to_string_pretty(&content)?;
116    std::fs::write(&gcode_json, &json_str)
117        .with_context(|| format!("failed to write {}", gcode_json.display()))?;
118
119    Ok((project_id, true))
120}
121
122/// Check whether any identity file exists for this project root.
123pub fn has_identity_file(project_root: &Path) -> bool {
124    let gobby_dir = project_root.join(".gobby");
125    gobby_dir.join("project.json").exists() || gobby_dir.join("gcode.json").exists()
126}
127
128// ── Internal helpers ────────────────────────────────────────────────
129
130/// Format current UTC time as ISO 8601 (no chrono dependency).
131fn now_iso8601() -> String {
132    use std::time::{SystemTime, UNIX_EPOCH};
133
134    let dur = SystemTime::now()
135        .duration_since(UNIX_EPOCH)
136        .unwrap_or_default();
137    let secs = dur.as_secs();
138    let micros = dur.subsec_micros();
139
140    let (y, m, d) = days_to_ymd(secs / 86400);
141    let daytime = secs % 86400;
142    let h = daytime / 3600;
143    let min = (daytime % 3600) / 60;
144    let s = daytime % 60;
145
146    format!("{y:04}-{m:02}-{d:02}T{h:02}:{min:02}:{s:02}.{micros:06}+00:00")
147}
148
149/// Convert days since Unix epoch to (year, month, day).
150/// Howard Hinnant's civil_from_days algorithm.
151fn days_to_ymd(days: u64) -> (u64, u64, u64) {
152    let z = days as i64 + 719468;
153    let era = if z >= 0 { z } else { z - 146096 } / 146097;
154    let doe = (z - era * 146097) as u64;
155    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
156    let y = yoe as i64 + era * 400;
157    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
158    let mp = (5 * doy + 2) / 153;
159    let d = doy - (153 * mp + 2) / 5 + 1;
160    let m = if mp < 10 { mp + 3 } else { mp - 9 };
161    let y = if m <= 2 { y + 1 } else { y };
162    (y as u64, m, d)
163}
164
165fn absolute_fallback(path: &Path) -> PathBuf {
166    if path.is_absolute() {
167        path.to_path_buf()
168    } else {
169        std::env::current_dir()
170            .unwrap_or_else(|_| PathBuf::from("."))
171            .join(path)
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn test_generate_project_id_deterministic() {
181        let dir = tempfile::tempdir().unwrap();
182        let id1 = generate_project_id(dir.path());
183        let id2 = generate_project_id(dir.path());
184        assert_eq!(id1, id2);
185        // Should be valid UUID
186        assert!(uuid::Uuid::parse_str(&id1).is_ok());
187    }
188
189    #[test]
190    fn test_generate_project_id_different_paths() {
191        let dir1 = tempfile::tempdir().unwrap();
192        let dir2 = tempfile::tempdir().unwrap();
193        let id1 = generate_project_id(dir1.path());
194        let id2 = generate_project_id(dir2.path());
195        assert_ne!(id1, id2);
196    }
197
198    #[test]
199    fn test_read_isolation_marker_detects_parent_fields() {
200        let dir = tempfile::tempdir().unwrap();
201        let gobby_dir = dir.path().join(".gobby");
202        std::fs::create_dir_all(&gobby_dir).unwrap();
203        std::fs::write(
204            gobby_dir.join("project.json"),
205            serde_json::json!({
206                "id": "copied-parent-id",
207                "parent_project_path": "/parent/root",
208                "parent_project_id": "parent-id"
209            })
210            .to_string(),
211        )
212        .unwrap();
213
214        let marker = read_isolation_marker(dir.path()).expect("isolation marker");
215
216        assert_eq!(marker.parent_project_path.as_deref(), Some("/parent/root"));
217        assert_eq!(marker.parent_project_id.as_deref(), Some("parent-id"));
218    }
219
220    #[test]
221    fn test_ensure_gcode_json_creates_new() {
222        let dir = tempfile::tempdir().unwrap();
223        let (id, created) = ensure_gcode_json(dir.path()).unwrap();
224        assert!(created);
225        assert!(uuid::Uuid::parse_str(&id).is_ok());
226
227        // Verify file exists with correct content
228        let path = dir.path().join(".gobby").join("gcode.json");
229        assert!(path.exists());
230        let contents: serde_json::Value =
231            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
232        assert_eq!(contents["id"].as_str().unwrap(), id);
233
234        // ID should match deterministic generation
235        assert_eq!(id, generate_project_id(dir.path()));
236    }
237
238    #[test]
239    fn test_ensure_gcode_json_skips_when_project_json_exists() {
240        let dir = tempfile::tempdir().unwrap();
241        let gobby_dir = dir.path().join(".gobby");
242        std::fs::create_dir_all(&gobby_dir).unwrap();
243
244        // Write a gobby project.json
245        let project_json = serde_json::json!({
246            "id": "gobby-owned-id-123",
247            "name": "test-project"
248        });
249        std::fs::write(
250            gobby_dir.join("project.json"),
251            serde_json::to_string_pretty(&project_json).unwrap(),
252        )
253        .unwrap();
254
255        let (id, created) = ensure_gcode_json(dir.path()).unwrap();
256        assert!(!created);
257        assert_eq!(id, "gobby-owned-id-123");
258
259        // gcode.json should NOT exist
260        assert!(!gobby_dir.join("gcode.json").exists());
261    }
262
263    #[test]
264    fn test_ensure_gcode_json_reads_existing() {
265        let dir = tempfile::tempdir().unwrap();
266
267        // Create gcode.json first
268        let (id1, created1) = ensure_gcode_json(dir.path()).unwrap();
269        assert!(created1);
270
271        // Second call should read, not overwrite
272        let original_bytes = std::fs::read(dir.path().join(".gobby").join("gcode.json")).unwrap();
273        let (id2, created2) = ensure_gcode_json(dir.path()).unwrap();
274        assert!(!created2);
275        assert_eq!(id1, id2);
276
277        // File should be byte-identical
278        let after_bytes = std::fs::read(dir.path().join(".gobby").join("gcode.json")).unwrap();
279        assert_eq!(original_bytes, after_bytes);
280    }
281
282    #[test]
283    fn test_now_iso8601_format() {
284        let ts = now_iso8601();
285        // Should match YYYY-MM-DDTHH:MM:SS.ffffff+00:00
286        assert!(ts.len() >= 30, "timestamp too short: {ts}");
287        assert!(ts.ends_with("+00:00"));
288        assert!(ts.contains('T'));
289    }
290
291    #[test]
292    fn test_has_identity_file() {
293        let dir = tempfile::tempdir().unwrap();
294        assert!(!has_identity_file(dir.path()));
295
296        let gobby_dir = dir.path().join(".gobby");
297        std::fs::create_dir_all(&gobby_dir).unwrap();
298        std::fs::write(gobby_dir.join("gcode.json"), "{}").unwrap();
299        assert!(has_identity_file(dir.path()));
300    }
301}