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 (gcode-owned identity) > 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/// Read the isolated-root marker from `.gobby/project.json`, if present.
47pub fn read_isolation_marker(project_root: &Path) -> Option<IsolationMarker> {
48    let path = project_root.join(".gobby").join("project.json");
49    let contents = std::fs::read_to_string(path).ok()?;
50    let json: serde_json::Value = serde_json::from_str(&contents).ok()?;
51    let parent_project_path = json
52        .get("parent_project_path")
53        .and_then(|v| v.as_str())
54        .filter(|s| !s.is_empty())
55        .map(ToOwned::to_owned);
56    let parent_project_id = json
57        .get("parent_project_id")
58        .and_then(|v| v.as_str())
59        .filter(|s| !s.is_empty())
60        .map(ToOwned::to_owned);
61
62    if parent_project_path.is_some() || parent_project_id.is_some() {
63        Some(IsolationMarker {
64            parent_project_path,
65            parent_project_id,
66        })
67    } else {
68        None
69    }
70}
71
72/// Ensure a gcode identity file exists. Non-destructive:
73/// - If `project.json` exists, reads its ID (gobby owns this project)
74/// - If `gcode.json` exists, reads its ID
75/// - If neither exists, creates `gcode.json`
76///
77/// Returns `(project_id, was_created)`.
78pub fn ensure_gcode_json(project_root: &Path) -> anyhow::Result<(String, bool)> {
79    // Gobby's file takes priority
80    let project_json = project_root.join(".gobby").join("project.json");
81    if project_json.exists() {
82        return Ok((read_project_id(project_root)?, false));
83    }
84
85    // Already initialized by gcode
86    let gcode_json = project_root.join(".gobby").join("gcode.json");
87    if gcode_json.exists() {
88        return Ok((read_gcode_json(project_root)?, false));
89    }
90
91    // Create .gobby/ directory and gcode.json
92    let gobby_dir = project_root.join(".gobby");
93    std::fs::create_dir_all(&gobby_dir)
94        .with_context(|| format!("failed to create {}", gobby_dir.display()))?;
95
96    let project_id = code_index_id_for_root(project_root);
97    let project_name = project_root
98        .file_name()
99        .map(|n| n.to_string_lossy().to_string())
100        .unwrap_or_else(|| "unknown".to_string());
101
102    let created_at = now_iso8601();
103
104    let content = serde_json::json!({
105        "id": project_id,
106        "name": project_name,
107        "created_at": created_at
108    });
109
110    let json_str = serde_json::to_string_pretty(&content)?;
111    std::fs::write(&gcode_json, &json_str)
112        .with_context(|| format!("failed to write {}", gcode_json.display()))?;
113
114    Ok((project_id, true))
115}
116
117/// Check whether any identity file exists for this project root.
118pub fn has_identity_file(project_root: &Path) -> bool {
119    let gobby_dir = project_root.join(".gobby");
120    gobby_dir.join("project.json").exists() || gobby_dir.join("gcode.json").exists()
121}
122
123// ── Internal helpers ────────────────────────────────────────────────
124
125/// Format current UTC time as ISO 8601.
126fn now_iso8601() -> String {
127    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
128}
129
130fn absolute_fallback(path: &Path) -> PathBuf {
131    if path.is_absolute() {
132        path.to_path_buf()
133    } else {
134        std::env::current_dir()
135            .unwrap_or_else(|_| std::env::temp_dir())
136            .join(path)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_code_index_id_for_root_deterministic() {
146        let dir = tempfile::tempdir().unwrap();
147        let id1 = code_index_id_for_root(dir.path());
148        let id2 = code_index_id_for_root(dir.path());
149        assert_eq!(id1, id2);
150        // Should be valid UUID
151        assert!(uuid::Uuid::parse_str(&id1).is_ok());
152    }
153
154    #[test]
155    fn test_code_index_id_for_root_different_paths() {
156        let dir1 = tempfile::tempdir().unwrap();
157        let dir2 = tempfile::tempdir().unwrap();
158        let id1 = code_index_id_for_root(dir1.path());
159        let id2 = code_index_id_for_root(dir2.path());
160        assert_ne!(id1, id2);
161    }
162
163    #[test]
164    fn test_read_isolation_marker_detects_parent_fields() {
165        let dir = tempfile::tempdir().unwrap();
166        let gobby_dir = dir.path().join(".gobby");
167        std::fs::create_dir_all(&gobby_dir).unwrap();
168        std::fs::write(
169            gobby_dir.join("project.json"),
170            serde_json::json!({
171                "id": "copied-parent-id",
172                "parent_project_path": "/parent/root",
173                "parent_project_id": "parent-id"
174            })
175            .to_string(),
176        )
177        .unwrap();
178
179        let marker = read_isolation_marker(dir.path()).expect("isolation marker");
180
181        assert_eq!(marker.parent_project_path.as_deref(), Some("/parent/root"));
182        assert_eq!(marker.parent_project_id.as_deref(), Some("parent-id"));
183    }
184
185    #[test]
186    fn test_ensure_gcode_json_creates_new() {
187        let dir = tempfile::tempdir().unwrap();
188        let (id, created) = ensure_gcode_json(dir.path()).unwrap();
189        assert!(created);
190        assert!(uuid::Uuid::parse_str(&id).is_ok());
191
192        // Verify file exists with correct content
193        let path = dir.path().join(".gobby").join("gcode.json");
194        assert!(path.exists());
195        let contents: serde_json::Value =
196            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
197        assert_eq!(contents["id"].as_str().unwrap(), id);
198
199        // ID should match deterministic generation
200        assert_eq!(id, code_index_id_for_root(dir.path()));
201    }
202
203    #[test]
204    fn test_ensure_gcode_json_skips_when_project_json_exists() {
205        let dir = tempfile::tempdir().unwrap();
206        let gobby_dir = dir.path().join(".gobby");
207        std::fs::create_dir_all(&gobby_dir).unwrap();
208
209        // Write a gobby project.json
210        let project_json = serde_json::json!({
211            "id": "gobby-owned-id-123",
212            "name": "test-project"
213        });
214        std::fs::write(
215            gobby_dir.join("project.json"),
216            serde_json::to_string_pretty(&project_json).unwrap(),
217        )
218        .unwrap();
219
220        let (id, created) = ensure_gcode_json(dir.path()).unwrap();
221        assert!(!created);
222        assert_eq!(id, "gobby-owned-id-123");
223
224        // gcode.json should NOT exist
225        assert!(!gobby_dir.join("gcode.json").exists());
226    }
227
228    #[test]
229    fn test_ensure_gcode_json_reads_existing() {
230        let dir = tempfile::tempdir().unwrap();
231
232        // Create gcode.json first
233        let (id1, created1) = ensure_gcode_json(dir.path()).unwrap();
234        assert!(created1);
235
236        // Second call should read, not overwrite
237        let original_bytes = std::fs::read(dir.path().join(".gobby").join("gcode.json")).unwrap();
238        let (id2, created2) = ensure_gcode_json(dir.path()).unwrap();
239        assert!(!created2);
240        assert_eq!(id1, id2);
241
242        // File should be byte-identical
243        let after_bytes = std::fs::read(dir.path().join(".gobby").join("gcode.json")).unwrap();
244        assert_eq!(original_bytes, after_bytes);
245    }
246
247    #[test]
248    fn test_now_iso8601_format() {
249        let ts = now_iso8601();
250        // Should match YYYY-MM-DDTHH:MM:SS.ffffffZ
251        assert!(ts.len() >= 27, "timestamp too short: {ts}");
252        assert!(ts.ends_with('Z'));
253        assert!(ts.contains('T'));
254    }
255
256    #[test]
257    fn test_has_identity_file() {
258        let dir = tempfile::tempdir().unwrap();
259        assert!(!has_identity_file(dir.path()));
260
261        let gobby_dir = dir.path().join(".gobby");
262        std::fs::create_dir_all(&gobby_dir).unwrap();
263        std::fs::write(gobby_dir.join("gcode.json"), "{}").unwrap();
264        assert!(has_identity_file(dir.path()));
265    }
266}