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