Skip to main content

thoughts_tool/workspace/
mod.rs

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