1use 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
20pub 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
32pub 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
46pub 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
72pub fn ensure_gcode_json(project_root: &Path) -> anyhow::Result<(String, bool)> {
79 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 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 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
117pub 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
123fn 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 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 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 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 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 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 let (id1, created1) = ensure_gcode_json(dir.path()).unwrap();
234 assert!(created1);
235
236 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 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 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}