thoughts_tool/workspace/
mod.rs

1use anyhow::{Context, Result};
2use atomicwrites::{AtomicFile, OverwriteBehavior};
3use serde_json::json;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7use tracing::debug;
8
9use crate::config::{Mount, RepoConfigManager};
10use crate::git::utils::{
11    find_repo_root, get_control_repo_root, get_current_branch, get_remote_url,
12};
13use crate::mount::MountResolver;
14
15// Centralized main/master detection
16fn is_main_like(branch: &str) -> bool {
17    matches!(branch, "main" | "master")
18}
19
20// Standardized lockout error text for CLI + MCP
21fn main_branch_lockout_error(branch: &str) -> anyhow::Error {
22    anyhow::anyhow!(
23        "Branch protection: operations that create or access branch-specific work are blocked on '{}'.\n\
24         Create a feature branch first, then re-run:\n  git checkout -b my/feature\n\n\
25         Note: branch-agnostic commands like 'thoughts work list' and 'thoughts references list' are allowed on main.",
26        branch
27    )
28}
29
30// Detect weekly dir formats "YYYY-WWW" and legacy "YYYY_week_WW"
31fn is_weekly_dir_name(name: &str) -> bool {
32    // Pattern 1: YYYY-WWW (e.g., "2025-W01")
33    if let Some((year, rest)) = name.split_once("-W")
34        && year.len() == 4
35        && year.chars().all(|c| c.is_ascii_digit())
36        && rest.len() == 2
37        && rest.chars().all(|c| c.is_ascii_digit())
38        && let Ok(w) = rest.parse::<u32>()
39    {
40        return (1..=53).contains(&w);
41    }
42    // Pattern 2 (legacy): YYYY_week_WW (e.g., "2025_week_01")
43    if let Some((year, rest)) = name.split_once("_week_")
44        && year.len() == 4
45        && year.chars().all(|c| c.is_ascii_digit())
46        && rest.len() == 2
47        && rest.chars().all(|c| c.is_ascii_digit())
48        && let Ok(w) = rest.parse::<u32>()
49    {
50        return (1..=53).contains(&w);
51    }
52    false
53}
54
55// Choose collision-free archive name (name, name-migrated, name-migrated-2, ...)
56fn next_archive_name(completed_dir: &Path, base_name: &str) -> PathBuf {
57    let candidate = completed_dir.join(base_name);
58    if !candidate.exists() {
59        return candidate;
60    }
61    let mut i = 1usize;
62    loop {
63        let with_suffix = if i == 1 {
64            format!("{}-migrated", base_name)
65        } else {
66            format!("{}-migrated-{}", base_name, i)
67        };
68        let p = completed_dir.join(with_suffix);
69        if !p.exists() {
70            return p;
71        }
72        i += 1;
73    }
74}
75
76// Auto-archive weekly dirs from thoughts_root/* -> thoughts_root/completed/*
77fn auto_archive_weekly_dirs(thoughts_root: &Path) -> Result<()> {
78    let completed = thoughts_root.join("completed");
79    std::fs::create_dir_all(&completed).ok();
80    for entry in std::fs::read_dir(thoughts_root)? {
81        let entry = entry?;
82        let p = entry.path();
83        if !p.is_dir() {
84            continue;
85        }
86        let name = entry.file_name();
87        let name = name.to_string_lossy();
88        if name == "completed" || name == "active" {
89            continue;
90        }
91        if is_weekly_dir_name(&name) {
92            let dest = next_archive_name(&completed, &name);
93            debug!("Archiving weekly dir {} -> {}", p.display(), dest.display());
94            std::fs::rename(&p, &dest).with_context(|| {
95                format!(
96                    "Failed to archive weekly dir {} -> {}",
97                    p.display(),
98                    dest.display()
99                )
100            })?;
101        }
102    }
103    Ok(())
104}
105
106/// Migrate from `thoughts/active/*` structure to `thoughts/*`.
107///
108/// Moves directories from active/ to the root and creates a compatibility
109/// symlink `active -> .` for backward compatibility.
110fn migrate_active_layer(thoughts_root: &Path) -> Result<()> {
111    let active = thoughts_root.join("active");
112
113    // Check if active is a real directory (not already a symlink)
114    if active.exists() && active.is_dir() && !active.is_symlink() {
115        debug!("Migrating active/ layer at {}", thoughts_root.display());
116
117        // Move all directories from active/ to thoughts_root
118        for entry in std::fs::read_dir(&active)? {
119            let entry = entry?;
120            let p = entry.path();
121            if p.is_dir() {
122                let name = entry.file_name();
123                let newp = thoughts_root.join(&name);
124                if !newp.exists() {
125                    std::fs::rename(&p, &newp).with_context(|| {
126                        format!("Failed to move {} to {}", p.display(), newp.display())
127                    })?;
128                    debug!("Migrated {} -> {}", p.display(), newp.display());
129                }
130            }
131        }
132
133        // Create compatibility symlink active -> .
134        #[cfg(unix)]
135        {
136            use std::os::unix::fs as unixfs;
137            // Only remove if it's now empty
138            if std::fs::read_dir(&active)?.next().is_none() {
139                let _ = std::fs::remove_dir(&active);
140                if unixfs::symlink(".", &active).is_ok() {
141                    debug!("Created compatibility symlink: active -> .");
142                }
143            }
144        }
145    }
146    Ok(())
147}
148
149/// Paths for the current active work directory
150#[derive(Debug, Clone)]
151pub struct ActiveWork {
152    pub dir_name: String,
153    pub base: PathBuf,
154    pub research: PathBuf,
155    pub plans: PathBuf,
156    pub artifacts: PathBuf,
157    pub logs: PathBuf,
158}
159
160/// Resolve thoughts root via configured thoughts_mount
161fn resolve_thoughts_root() -> Result<PathBuf> {
162    let control_root = get_control_repo_root(&std::env::current_dir()?)?;
163    let mgr = RepoConfigManager::new(control_root);
164    let ds = mgr.load_desired_state()?.ok_or_else(|| {
165        anyhow::anyhow!("No repository configuration found. Run 'thoughts init'.")
166    })?;
167
168    let tm = ds.thoughts_mount.as_ref().ok_or_else(|| {
169        anyhow::anyhow!(
170            "No thoughts_mount configured in repository configuration.\n\
171             Add thoughts_mount to .thoughts/config.json and run 'thoughts mount update'."
172        )
173    })?;
174
175    let resolver = MountResolver::new()?;
176    let mount = Mount::Git {
177        url: tm.remote.clone(),
178        subpath: tm.subpath.clone(),
179        sync: tm.sync,
180    };
181
182    resolver
183        .resolve_mount(&mount)
184        .context("Thoughts mount not cloned. Run 'thoughts sync' or 'thoughts mount update' first.")
185}
186
187/// Public helper for commands that must not create dirs (e.g., work complete).
188/// Runs migration and auto-archive, then enforces branch lockout.
189pub fn check_branch_allowed() -> Result<()> {
190    let thoughts_root = resolve_thoughts_root()?;
191    // Preserve legacy migration then auto-archive
192    migrate_active_layer(&thoughts_root)?;
193    auto_archive_weekly_dirs(&thoughts_root)?;
194    let code_root = find_repo_root(&std::env::current_dir()?)?;
195    let branch = get_current_branch(&code_root)?;
196    if is_main_like(&branch) {
197        return Err(main_branch_lockout_error(&branch));
198    }
199    Ok(())
200}
201
202/// Ensure active work directory exists with subdirs and manifest.
203/// Fails on main/master; never creates weekly directories.
204pub fn ensure_active_work() -> Result<ActiveWork> {
205    let thoughts_root = resolve_thoughts_root()?;
206
207    // Run migrations before any branch checks
208    migrate_active_layer(&thoughts_root)?;
209    auto_archive_weekly_dirs(&thoughts_root)?;
210
211    // Get branch and enforce lockout
212    let code_root = find_repo_root(&std::env::current_dir()?)?;
213    let branch = get_current_branch(&code_root)?;
214    if is_main_like(&branch) {
215        return Err(main_branch_lockout_error(&branch));
216    }
217
218    // Use branch name directly - no weekly directories
219    let dir_name = branch.clone();
220    let base = thoughts_root.join(&dir_name);
221
222    // Create structure if missing
223    if !base.exists() {
224        fs::create_dir_all(base.join("research")).context("Failed to create research directory")?;
225        fs::create_dir_all(base.join("plans")).context("Failed to create plans directory")?;
226        fs::create_dir_all(base.join("artifacts"))
227            .context("Failed to create artifacts directory")?;
228        fs::create_dir_all(base.join("logs")).context("Failed to create logs directory")?;
229
230        // Create manifest.json atomically
231        let source_repo = get_remote_url(&code_root).unwrap_or_else(|_| "unknown".to_string());
232        let manifest = json!({
233            "source_repo": source_repo,
234            "branch_or_week": dir_name,
235            "started_at": chrono::Utc::now().to_rfc3339(),
236        });
237
238        let manifest_path = base.join("manifest.json");
239        AtomicFile::new(&manifest_path, OverwriteBehavior::AllowOverwrite)
240            .write(|f| f.write_all(serde_json::to_string_pretty(&manifest)?.as_bytes()))
241            .with_context(|| format!("Failed to write manifest at {}", manifest_path.display()))?;
242    } else {
243        // Ensure subdirs exist even if base exists
244        for sub in ["research", "plans", "artifacts", "logs"] {
245            let subdir = base.join(sub);
246            if !subdir.exists() {
247                fs::create_dir_all(&subdir)
248                    .with_context(|| format!("Failed to ensure {} directory", sub))?;
249            }
250        }
251        // Ensure manifest exists
252        let manifest_path = base.join("manifest.json");
253        if !manifest_path.exists() {
254            let source_repo = get_remote_url(&code_root).unwrap_or_else(|_| "unknown".to_string());
255            let manifest = json!({
256                "source_repo": source_repo,
257                "branch_or_week": dir_name,
258                "started_at": chrono::Utc::now().to_rfc3339(),
259            });
260            AtomicFile::new(&manifest_path, OverwriteBehavior::AllowOverwrite)
261                .write(|f| f.write_all(serde_json::to_string_pretty(&manifest)?.as_bytes()))
262                .with_context(|| {
263                    format!("Failed to write manifest at {}", manifest_path.display())
264                })?;
265        }
266    }
267
268    Ok(ActiveWork {
269        dir_name: dir_name.clone(),
270        base: base.clone(),
271        research: base.join("research"),
272        plans: base.join("plans"),
273        artifacts: base.join("artifacts"),
274        logs: base.join("logs"),
275    })
276}
277
278#[cfg(test)]
279mod branch_lock_tests {
280    use super::*;
281    use std::fs;
282    use tempfile::TempDir;
283
284    #[test]
285    fn is_main_like_detection() {
286        assert!(is_main_like("main"));
287        assert!(is_main_like("master"));
288        assert!(!is_main_like("feature/login"));
289        assert!(!is_main_like("main-feature"));
290        assert!(!is_main_like("my-master"));
291    }
292
293    #[test]
294    fn weekly_name_detection() {
295        // Valid new format: YYYY-WWW
296        assert!(is_weekly_dir_name("2025-W01"));
297        assert!(is_weekly_dir_name("2024-W53"));
298        assert!(is_weekly_dir_name("2020-W10"));
299
300        // Valid legacy format: YYYY_week_WW
301        assert!(is_weekly_dir_name("2024_week_52"));
302        assert!(is_weekly_dir_name("2025_week_01"));
303
304        // Invalid: branch names
305        assert!(!is_weekly_dir_name("feat/login-page"));
306        assert!(!is_weekly_dir_name("main"));
307        assert!(!is_weekly_dir_name("master"));
308        assert!(!is_weekly_dir_name("feature-2025-W01"));
309
310        // Invalid: out of range weeks
311        assert!(!is_weekly_dir_name("2025-W00"));
312        assert!(!is_weekly_dir_name("2025-W54"));
313        assert!(!is_weekly_dir_name("2025_week_00"));
314        assert!(!is_weekly_dir_name("2025_week_54"));
315
316        // Invalid: malformed
317        assert!(!is_weekly_dir_name("2025-W1")); // single digit week
318        assert!(!is_weekly_dir_name("202-W01")); // 3 digit year
319        assert!(!is_weekly_dir_name("2025_week_1")); // single digit week
320    }
321
322    #[test]
323    fn auto_archive_moves_weekly_dirs() {
324        let temp = TempDir::new().unwrap();
325        let root = temp.path();
326
327        // Create weekly dirs to archive
328        fs::create_dir_all(root.join("2025-W01")).unwrap();
329        fs::create_dir_all(root.join("2024_week_52")).unwrap();
330        // Create non-weekly dir that should NOT be archived
331        fs::create_dir_all(root.join("feature-branch")).unwrap();
332
333        auto_archive_weekly_dirs(root).unwrap();
334
335        // Weekly dirs should be moved to completed/
336        assert!(!root.join("2025-W01").exists());
337        assert!(!root.join("2024_week_52").exists());
338        assert!(root.join("completed/2025-W01").exists());
339        assert!(root.join("completed/2024_week_52").exists());
340
341        // Non-weekly dir should remain
342        assert!(root.join("feature-branch").exists());
343    }
344
345    #[test]
346    fn auto_archive_handles_collision() {
347        let temp = TempDir::new().unwrap();
348        let root = temp.path();
349
350        // Create completed dir with existing entry
351        fs::create_dir_all(root.join("completed/2025-W01")).unwrap();
352        // Create weekly dir to archive (will collide)
353        fs::create_dir_all(root.join("2025-W01")).unwrap();
354
355        auto_archive_weekly_dirs(root).unwrap();
356
357        // Should be archived with -migrated suffix
358        assert!(!root.join("2025-W01").exists());
359        assert!(root.join("completed/2025-W01").exists());
360        assert!(root.join("completed/2025-W01-migrated").exists());
361    }
362
363    #[test]
364    fn auto_archive_multiple_collision() {
365        let temp = TempDir::new().unwrap();
366        let root = temp.path();
367
368        // Pre-existing archived entries that will cause multiple collisions
369        fs::create_dir_all(root.join("completed/2025-W01")).unwrap();
370        fs::create_dir_all(root.join("completed/2025-W01-migrated")).unwrap();
371
372        // Create the weekly dir that should be archived and collide twice
373        fs::create_dir_all(root.join("2025-W01")).unwrap();
374
375        auto_archive_weekly_dirs(root).unwrap();
376
377        // Source should be moved
378        assert!(!root.join("2025-W01").exists());
379        // Original and first migrated remain
380        assert!(root.join("completed/2025-W01").exists());
381        assert!(root.join("completed/2025-W01-migrated").exists());
382        // New archive should be suffixed with -migrated-2
383        assert!(root.join("completed/2025-W01-migrated-2").exists());
384    }
385
386    #[test]
387    fn auto_archive_idempotent() {
388        let temp = TempDir::new().unwrap();
389        let root = temp.path();
390
391        // No weekly dirs to archive
392        fs::create_dir_all(root.join("feature-branch")).unwrap();
393        fs::create_dir_all(root.join("completed")).unwrap();
394
395        // Should not fail and should not move anything
396        auto_archive_weekly_dirs(root).unwrap();
397        auto_archive_weekly_dirs(root).unwrap();
398
399        assert!(root.join("feature-branch").exists());
400    }
401
402    #[test]
403    fn lockout_error_message_format() {
404        let err = main_branch_lockout_error("main");
405        let msg = err.to_string();
406        // Verify standardized message components
407        assert!(msg.contains("Branch protection"));
408        assert!(msg.contains("'main'"));
409        assert!(msg.contains("git checkout -b"));
410        assert!(msg.contains("work list"));
411    }
412}