Skip to main content

thoughts_tool/workspace/
mod.rs

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