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 generate_project_id(project_root: &Path) -> String {
48 code_index_id_for_root(project_root)
49}
50
51pub 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
77pub fn ensure_gcode_json(project_root: &Path) -> anyhow::Result<(String, bool)> {
84 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 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 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
122pub 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
128fn 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
149fn 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 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 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 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 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 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 let (id1, created1) = ensure_gcode_json(dir.path()).unwrap();
269 assert!(created1);
270
271 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 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 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}