1use std::fs;
4use std::path::{Path, PathBuf};
5
6use chrono::Utc;
7use fs2::FileExt;
8use uuid::Uuid;
9
10use crate::error::{GCCError, Result};
11use crate::models::{
12 desanitize, split_blocks, BranchMetadata, CommitRecord, ContextResult, OTARecord,
13};
14
15const MAIN_BRANCH: &str = "main";
16const GCC_DIR: &str = ".GCC";
17
18fn now() -> String {
19 Utc::now().to_rfc3339()
20}
21
22fn short_id() -> String {
23 Uuid::new_v4().to_string()[..8].to_string()
24}
25
26fn validate_not_empty(value: &str, field: &str) -> Result<()> {
28 if value.trim().is_empty() {
29 return Err(GCCError::Validation(format!("{field} must not be empty")));
30 }
31 Ok(())
32}
33
34fn validate_branch_name(name: &str) -> Result<()> {
36 validate_not_empty(name, "Branch name")?;
37 if name.contains('/') || name.contains('\\') {
38 return Err(GCCError::Validation(format!(
39 "Branch name must not contain path separators: {name:?}"
40 )));
41 }
42 if name == "." || name == ".." {
43 return Err(GCCError::Validation(format!(
44 "Branch name must not be '.' or '..': {name:?}"
45 )));
46 }
47 Ok(())
48}
49
50pub struct GCCWorkspace {
58 gcc_dir: PathBuf,
59 current_branch: String,
60}
61
62struct FileLock {
64 _file: fs::File,
65}
66
67impl FileLock {
68 fn acquire(gcc_dir: &Path) -> Result<Self> {
69 let lock_path = gcc_dir.join(".lock");
70 if let Some(parent) = lock_path.parent() {
71 fs::create_dir_all(parent)?;
72 }
73 let file = fs::OpenOptions::new()
74 .create(true)
75 .read(true)
76 .write(true)
77 .open(&lock_path)?;
78 file.lock_exclusive()
79 .map_err(|e| GCCError::Lock(e.to_string()))?;
80 Ok(Self { _file: file })
81 }
82}
83
84impl Drop for FileLock {
85 fn drop(&mut self) {
86 let _ = self._file.unlock();
87 }
88}
89
90impl GCCWorkspace {
91 pub fn new(project_root: impl AsRef<Path>) -> Self {
92 let gcc_dir = project_root.as_ref().join(GCC_DIR);
93 Self {
94 gcc_dir,
95 current_branch: MAIN_BRANCH.to_string(),
96 }
97 }
98
99 fn branch_dir(&self, branch: &str) -> PathBuf {
104 self.gcc_dir.join("branches").join(branch)
105 }
106
107 fn log_path(&self, branch: &str) -> PathBuf {
108 self.branch_dir(branch).join("log.md")
109 }
110
111 fn commit_path(&self, branch: &str) -> PathBuf {
112 self.branch_dir(branch).join("commit.md")
113 }
114
115 fn meta_path(&self, branch: &str) -> PathBuf {
116 self.branch_dir(branch).join("metadata.yaml")
117 }
118
119 fn main_md(&self) -> PathBuf {
120 self.gcc_dir.join("main.md")
121 }
122
123 fn read(&self, path: &Path) -> String {
128 fs::read_to_string(path).unwrap_or_default()
129 }
130
131 fn write(&self, path: &Path, content: &str) -> Result<()> {
132 if let Some(parent) = path.parent() {
133 fs::create_dir_all(parent)?;
134 }
135 fs::write(path, content)?;
136 Ok(())
137 }
138
139 fn append(&self, path: &Path, content: &str) -> Result<()> {
140 use std::io::Write;
141 let mut file = fs::OpenOptions::new()
142 .append(true)
143 .create(true)
144 .open(path)?;
145 file.write_all(content.as_bytes())?;
146 Ok(())
147 }
148
149 fn read_commits(&self, branch: &str) -> Vec<CommitRecord> {
150 let text = self.read(&self.commit_path(branch));
151 let mut records = Vec::new();
152 for block in split_blocks(&text) {
153 let block = block.trim();
154 if block.is_empty() {
155 continue;
156 }
157 let mut commit_id = String::new();
158 let mut ts = String::new();
159 let mut current_field: Option<&str> = None;
160 let mut fields: std::collections::HashMap<&str, String> =
161 std::collections::HashMap::new();
162
163 for line in block.lines() {
164 if line.starts_with("## Commit `") {
165 commit_id = line
166 .trim_start_matches("## Commit `")
167 .trim_end_matches('`')
168 .to_string();
169 current_field = None;
170 } else if line.starts_with("**Timestamp:**") {
171 ts = line.replace("**Timestamp:**", "").trim().to_string();
172 current_field = None;
173 } else if line.starts_with("**Branch Purpose:**") {
174 let val = line.replace("**Branch Purpose:**", "").trim().to_string();
175 fields.insert("branch_purpose", val);
176 current_field = Some("branch_purpose");
177 } else if line.starts_with("**Previous Progress Summary:**") {
178 let val = line
179 .replace("**Previous Progress Summary:**", "")
180 .trim()
181 .to_string();
182 fields.insert("prev_summary", val);
183 current_field = Some("prev_summary");
184 } else if line.starts_with("**This Commit's Contribution:**") {
185 let val = line
186 .replace("**This Commit's Contribution:**", "")
187 .trim()
188 .to_string();
189 fields.insert("contribution", val);
190 current_field = Some("contribution");
191 } else if let Some(field) = current_field {
192 let trimmed = line.trim();
193 if !trimmed.is_empty() {
194 if let Some(existing) = fields.get_mut(field) {
195 existing.push('\n');
196 existing.push_str(trimmed);
197 }
198 }
199 }
200 }
201 if !commit_id.is_empty() {
202 let get = |key: &str| -> String {
203 desanitize(fields.get(key).map(|s| s.trim()).unwrap_or(""))
204 };
205 records.push(CommitRecord {
206 commit_id,
207 branch_name: branch.to_string(),
208 branch_purpose: get("branch_purpose"),
209 previous_progress_summary: get("prev_summary"),
210 this_commit_contribution: get("contribution"),
211 timestamp: ts,
212 });
213 }
214 }
215 records
216 }
217
218 fn read_ota(&self, branch: &str) -> Vec<OTARecord> {
219 let text = self.read(&self.log_path(branch));
220 let mut records = Vec::new();
221 for block in split_blocks(&text) {
222 let block = block.trim();
223 if block.is_empty() {
224 continue;
225 }
226 let mut step = 0usize;
227 let mut ts = String::new();
228 let mut current_field: Option<&str> = None;
229 let mut fields: std::collections::HashMap<&str, String> =
230 std::collections::HashMap::new();
231
232 for line in block.lines() {
233 if line.starts_with("### Step ") {
234 let parts: Vec<&str> = line.splitn(2, '—').collect();
235 step = parts[0]
236 .replace("### Step ", "")
237 .trim()
238 .parse()
239 .unwrap_or(0);
240 ts = parts.get(1).unwrap_or(&"").trim().to_string();
241 current_field = None;
242 } else if line.starts_with("**Observation:**") {
243 let val = line.replace("**Observation:**", "").trim().to_string();
244 fields.insert("obs", val);
245 current_field = Some("obs");
246 } else if line.starts_with("**Thought:**") {
247 let val = line.replace("**Thought:**", "").trim().to_string();
248 fields.insert("thought", val);
249 current_field = Some("thought");
250 } else if line.starts_with("**Action:**") {
251 let val = line.replace("**Action:**", "").trim().to_string();
252 fields.insert("action", val);
253 current_field = Some("action");
254 } else if let Some(field) = current_field {
255 let trimmed = line.trim();
256 if !trimmed.is_empty() {
257 if let Some(existing) = fields.get_mut(field) {
258 existing.push('\n');
259 existing.push_str(trimmed);
260 }
261 }
262 }
263 }
264 let get = |key: &str| -> String {
265 desanitize(fields.get(key).map(|s| s.trim()).unwrap_or(""))
266 };
267 let obs = get("obs");
268 let thought = get("thought");
269 if !obs.is_empty() || !thought.is_empty() {
270 records.push(OTARecord {
271 step,
272 timestamp: ts,
273 observation: obs,
274 thought,
275 action: get("action"),
276 });
277 }
278 }
279 records
280 }
281
282 fn read_meta(&self, branch: &str) -> Option<BranchMetadata> {
283 let text = self.read(&self.meta_path(branch));
284 if text.is_empty() {
285 return None;
286 }
287 serde_yaml::from_str(&text).ok()
288 }
289
290 pub fn init(&mut self, project_roadmap: &str) -> Result<()> {
296 if self.gcc_dir.exists() {
297 return Err(GCCError::AlreadyExists {
298 path: self.gcc_dir.display().to_string(),
299 });
300 }
301
302 let main_dir = self.branch_dir(MAIN_BRANCH);
303 fs::create_dir_all(&main_dir)?;
304
305 let _lock = FileLock::acquire(&self.gcc_dir)?;
306
307 let roadmap = format!(
309 "# Project Roadmap\n\n**Initialized:** {}\n\n{}\n",
310 now(),
311 project_roadmap
312 );
313 self.write(&self.main_md(), &roadmap)?;
314
315 self.write(
317 &self.log_path(MAIN_BRANCH),
318 &format!("# OTA Log — branch `{MAIN_BRANCH}`\n\n"),
319 )?;
320 self.write(
321 &self.commit_path(MAIN_BRANCH),
322 &format!("# Commit History — branch `{MAIN_BRANCH}`\n\n"),
323 )?;
324
325 let meta = BranchMetadata {
327 name: MAIN_BRANCH.to_string(),
328 purpose: "Primary reasoning trajectory".to_string(),
329 created_from: String::new(),
330 created_at: now(),
331 status: "active".to_string(),
332 merged_into: None,
333 merged_at: None,
334 };
335 self.write(&self.meta_path(MAIN_BRANCH), &serde_yaml::to_string(&meta)?)?;
336
337 self.current_branch = MAIN_BRANCH.to_string();
338 Ok(())
339 }
340
341 pub fn load(&mut self) -> Result<()> {
343 if !self.gcc_dir.exists() {
344 return Err(GCCError::NotFound {
345 path: self.gcc_dir.display().to_string(),
346 });
347 }
348 self.current_branch = MAIN_BRANCH.to_string();
349 Ok(())
350 }
351
352 pub fn log_ota(&self, observation: &str, thought: &str, action: &str) -> Result<OTARecord> {
359 if observation.trim().is_empty() && thought.trim().is_empty() && action.trim().is_empty() {
360 return Err(GCCError::Validation(
361 "At least one of observation, thought, or action must be non-empty".to_string(),
362 ));
363 }
364 let _lock = FileLock::acquire(&self.gcc_dir)?;
365 let existing = self.read_ota(&self.current_branch);
366 let step = existing.len() + 1;
367 let record = OTARecord {
368 step,
369 timestamp: now(),
370 observation: observation.to_string(),
371 thought: thought.to_string(),
372 action: action.to_string(),
373 };
374 self.append(&self.log_path(&self.current_branch), &record.to_markdown())?;
375 Ok(record)
376 }
377
378 pub fn commit(
383 &self,
384 contribution: &str,
385 previous_summary: Option<&str>,
386 update_roadmap: Option<&str>,
387 ) -> Result<CommitRecord> {
388 validate_not_empty(contribution, "Contribution")?;
389 let _lock = FileLock::acquire(&self.gcc_dir)?;
390 self.commit_inner(contribution, previous_summary, update_roadmap)
391 }
392
393 fn commit_inner(
395 &self,
396 contribution: &str,
397 previous_summary: Option<&str>,
398 update_roadmap: Option<&str>,
399 ) -> Result<CommitRecord> {
400 let meta = self.read_meta(&self.current_branch);
401 let branch_purpose = meta.as_ref().map(|m| m.purpose.clone()).unwrap_or_default();
402
403 let prev = match previous_summary {
404 Some(s) => s.to_string(),
405 None => {
406 let commits = self.read_commits(&self.current_branch);
407 commits
408 .last()
409 .map(|c| c.this_commit_contribution.clone())
410 .unwrap_or_else(|| "Initial state — no prior commits.".to_string())
411 }
412 };
413
414 let record = CommitRecord {
415 commit_id: short_id(),
416 branch_name: self.current_branch.clone(),
417 branch_purpose,
418 previous_progress_summary: prev,
419 this_commit_contribution: contribution.to_string(),
420 timestamp: now(),
421 };
422
423 self.append(
424 &self.commit_path(&self.current_branch),
425 &record.to_markdown(),
426 )?;
427
428 if let Some(roadmap_update) = update_roadmap {
429 let update = format!("\n## Update ({})\n{}\n", record.timestamp, roadmap_update);
430 self.append(&self.main_md(), &update)?;
431 }
432
433 Ok(record)
434 }
435
436 pub fn branch(&mut self, name: &str, purpose: &str) -> Result<()> {
441 validate_branch_name(name)?;
442 validate_not_empty(purpose, "Branch purpose")?;
443 let branch_dir = self.branch_dir(name);
444 if branch_dir.exists() {
445 return Err(GCCError::BranchExists {
446 name: name.to_string(),
447 });
448 }
449
450 fs::create_dir_all(&branch_dir)?;
451
452 let _lock = FileLock::acquire(&self.gcc_dir)?;
453 self.write(
454 &self.log_path(name),
455 &format!("# OTA Log — branch `{name}`\n\n"),
456 )?;
457 self.write(
458 &self.commit_path(name),
459 &format!("# Commit History — branch `{name}`\n\n"),
460 )?;
461
462 let meta = BranchMetadata {
463 name: name.to_string(),
464 purpose: purpose.to_string(),
465 created_from: self.current_branch.clone(),
466 created_at: now(),
467 status: "active".to_string(),
468 merged_into: None,
469 merged_at: None,
470 };
471 self.write(&self.meta_path(name), &serde_yaml::to_string(&meta)?)?;
472
473 self.current_branch = name.to_string();
474 Ok(())
475 }
476
477 pub fn merge(
482 &mut self,
483 branch_name: &str,
484 summary: Option<&str>,
485 target: &str,
486 ) -> Result<CommitRecord> {
487 validate_branch_name(branch_name)?;
488 validate_branch_name(target)?;
489 if !self.branch_dir(branch_name).exists() {
490 return Err(GCCError::BranchNotFound {
491 name: branch_name.to_string(),
492 });
493 }
494
495 let _lock = FileLock::acquire(&self.gcc_dir)?;
496
497 let branch_commits = self.read_commits(branch_name);
498 let branch_ota = self.read_ota(branch_name);
499 let meta = self.read_meta(branch_name);
500
501 let merge_summary = match summary {
502 Some(s) => s.to_string(),
503 None => {
504 let contribs: Vec<_> = branch_commits
505 .iter()
506 .map(|c| c.this_commit_contribution.as_str())
507 .collect();
508 format!(
509 "Merged branch `{}` ({} commits). Contributions: {}",
510 branch_name,
511 branch_commits.len(),
512 contribs.join(" | ")
513 )
514 }
515 };
516
517 if !branch_ota.is_empty() {
519 let header = format!("\n## Merged from `{}` ({})\n\n", branch_name, now());
520 self.append(&self.log_path(target), &header)?;
521 for rec in &branch_ota {
522 self.append(&self.log_path(target), &rec.to_markdown())?;
523 }
524 }
525
526 self.current_branch = target.to_string();
528 let prev = format!(
529 "Merging branch `{}` with purpose: {}",
530 branch_name,
531 meta.as_ref().map(|m| m.purpose.as_str()).unwrap_or("")
532 );
533 let merge_commit = self.commit_inner(&merge_summary, Some(&prev), Some(&merge_summary))?;
534
535 if let Some(mut m) = meta {
537 m.status = "merged".to_string();
538 m.merged_into = Some(target.to_string());
539 m.merged_at = Some(now());
540 self.write(&self.meta_path(branch_name), &serde_yaml::to_string(&m)?)?;
541 }
542
543 Ok(merge_commit)
544 }
545
546 pub fn context(&self, branch: Option<&str>, k: usize) -> Result<ContextResult> {
551 if k < 1 {
552 return Err(GCCError::Validation(format!("k must be >= 1, got {k}")));
553 }
554 let _lock = FileLock::acquire(&self.gcc_dir)?;
555 let target = branch.unwrap_or(&self.current_branch);
556 if !self.branch_dir(target).exists() {
557 return Err(GCCError::BranchNotFound {
558 name: target.to_string(),
559 });
560 }
561
562 let all_commits = self.read_commits(target);
563 let skip = if all_commits.len() > k {
564 all_commits.len() - k
565 } else {
566 0
567 };
568 let commits = all_commits[skip..].to_vec();
569 let ota_records = self.read_ota(target);
570 let main_roadmap = self.read(&self.main_md());
571 let metadata = self.read_meta(target);
572
573 Ok(ContextResult {
574 branch_name: target.to_string(),
575 k,
576 commits,
577 ota_records,
578 main_roadmap,
579 metadata,
580 })
581 }
582
583 pub fn current_branch(&self) -> &str {
588 &self.current_branch
589 }
590
591 pub fn switch_branch(&mut self, name: &str) -> Result<()> {
592 validate_branch_name(name)?;
593 if !self.branch_dir(name).exists() {
594 return Err(GCCError::BranchNotFound {
595 name: name.to_string(),
596 });
597 }
598 self.current_branch = name.to_string();
599 Ok(())
600 }
601
602 pub fn list_branches(&self) -> Vec<String> {
603 let branches_root = self.gcc_dir.join("branches");
604 fs::read_dir(&branches_root)
605 .map(|entries| {
606 entries
607 .filter_map(|e| e.ok())
608 .filter(|e| e.path().is_dir())
609 .map(|e| e.file_name().to_string_lossy().to_string())
610 .collect()
611 })
612 .unwrap_or_default()
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use tempfile::TempDir;
620
621 fn workspace() -> (TempDir, GCCWorkspace) {
622 let dir = TempDir::new().unwrap();
623 let mut ws = GCCWorkspace::new(dir.path());
624 ws.init("Test project roadmap").unwrap();
625 (dir, ws)
626 }
627
628 #[test]
629 fn test_init_creates_gcc_structure() {
630 let (dir, _) = workspace();
631 assert!(dir.path().join(".GCC/main.md").exists());
632 assert!(dir.path().join(".GCC/branches/main/log.md").exists());
633 assert!(dir.path().join(".GCC/branches/main/commit.md").exists());
634 assert!(dir.path().join(".GCC/branches/main/metadata.yaml").exists());
635 }
636
637 #[test]
638 fn test_log_ota_increments_step() {
639 let (_dir, ws) = workspace();
640 let r1 = ws.log_ota("obs1", "thought1", "action1").unwrap();
641 let r2 = ws.log_ota("obs2", "thought2", "action2").unwrap();
642 assert_eq!(r1.step, 1);
643 assert_eq!(r2.step, 2);
644 }
645
646 #[test]
647 fn test_commit_writes_checkpoint() {
648 let (_dir, ws) = workspace();
649 let c = ws.commit("Initial scaffold done", None, None).unwrap();
650 assert_eq!(c.this_commit_contribution, "Initial scaffold done");
651 assert_eq!(c.branch_name, "main");
652 assert_eq!(c.commit_id.len(), 8);
653 }
654
655 #[test]
656 fn test_branch_creates_isolated_workspace() {
657 let (_dir, mut ws) = workspace();
658 ws.branch("experiment-a", "Try alternative algorithm")
659 .unwrap();
660 assert_eq!(ws.current_branch(), "experiment-a");
661 let ota = ws.read_ota("experiment-a");
662 assert!(ota.is_empty());
663 }
664
665 #[test]
666 fn test_merge_integrates_branch() {
667 let (_dir, mut ws) = workspace();
668 ws.commit("Main first commit", None, None).unwrap();
669 ws.branch("feature", "Add feature X").unwrap();
670 ws.log_ota("feature obs", "feature thought", "feature action")
671 .unwrap();
672 ws.commit("Feature X done", None, None).unwrap();
673 let merge_commit = ws.merge("feature", None, "main").unwrap();
674 assert!(merge_commit.this_commit_contribution.contains("feature"));
675 assert_eq!(ws.current_branch(), "main");
676 }
677
678 #[test]
679 fn test_context_k1_returns_last_commit() {
680 let (_dir, ws) = workspace();
681 ws.commit("C1", None, None).unwrap();
682 ws.commit("C2", None, None).unwrap();
683 ws.commit("C3", None, None).unwrap();
684 let ctx = ws.context(None, 1).unwrap();
685 assert_eq!(ctx.commits.len(), 1);
686 assert_eq!(ctx.commits[0].this_commit_contribution, "C3");
687 }
688
689 #[test]
690 fn test_branch_metadata_records_purpose() {
691 let (_dir, mut ws) = workspace();
692 ws.branch("jwt-branch", "JWT auth experiment").unwrap();
693 let meta = ws.read_meta("jwt-branch").unwrap();
694 assert_eq!(meta.purpose, "JWT auth experiment");
695 assert_eq!(meta.created_from, "main");
696 assert_eq!(meta.status, "active");
697 }
698
699 #[test]
700 fn test_merge_marks_branch_merged() {
701 let (_dir, mut ws) = workspace();
702 ws.branch("to-merge", "Will be merged").unwrap();
703 ws.commit("Branch work", None, None).unwrap();
704 ws.merge("to-merge", None, "main").unwrap();
705 let meta = ws.read_meta("to-merge").unwrap();
706 assert_eq!(meta.status, "merged");
707 assert_eq!(meta.merged_into.unwrap(), "main");
708 }
709}