Skip to main content

ralph_core/
loop_context.rs

1//! Loop context for path resolution in multi-loop scenarios.
2//!
3//! When running multiple Ralph loops concurrently, each loop needs its own
4//! isolated paths for state files (events, tasks, scratchpad) while sharing
5//! memories across loops for cross-loop learning.
6//!
7//! # Design
8//!
9//! - **Primary loop**: Runs in the main workspace, paths resolve to standard locations
10//! - **Worktree loop**: Runs in a git worktree, paths resolve to worktree-local locations
11//! - **Shared memories**: Memories are symlinked in worktrees, pointing to main workspace
12//! - **Shared specs/tasks**: Specs and code tasks are symlinked in worktrees
13//!
14//! # Directory Structure
15//!
16//! All Ralph state is consolidated under `.ralph/`:
17//! ```text
18//! .ralph/
19//! ├── agent/                    # Agent state (memories, tasks, scratchpad)
20//! │   ├── memories.md           # Symlinked in worktrees
21//! │   ├── tasks.jsonl           # Isolated per worktree
22//! │   ├── scratchpad.md         # Isolated per worktree
23//! │   └── context.md            # Worktree metadata (worktrees only)
24//! ├── specs/                    # Specification files (symlinked in worktrees)
25//! ├── tasks/                    # Code task files (symlinked in worktrees)
26//! ├── loop.lock
27//! ├── loops.json
28//! ├── merge-queue.jsonl
29//! ├── events.jsonl
30//! ├── current-events
31//! ├── history.jsonl
32//! ├── diagnostics/
33//! └── planning-sessions/
34//! ```
35//!
36//! # Example
37//!
38//! ```
39//! use ralph_core::loop_context::LoopContext;
40//! use std::path::PathBuf;
41//!
42//! // Primary loop runs in current directory
43//! let primary = LoopContext::primary(PathBuf::from("/project"));
44//! assert_eq!(primary.events_path().to_string_lossy(), "/project/.ralph/events.jsonl");
45//! assert_eq!(primary.tasks_path().to_string_lossy(), "/project/.ralph/agent/tasks.jsonl");
46//!
47//! // Worktree loop runs in isolated directory
48//! let worktree = LoopContext::worktree(
49//!     "loop-1234-abcd",
50//!     PathBuf::from("/project/.worktrees/loop-1234-abcd"),
51//!     PathBuf::from("/project"),
52//! );
53//! assert_eq!(worktree.events_path().to_string_lossy(),
54//!            "/project/.worktrees/loop-1234-abcd/.ralph/events.jsonl");
55//! ```
56
57use crate::text::truncate_with_ellipsis;
58use std::path::{Path, PathBuf};
59
60/// Context for resolving paths within a Ralph loop.
61///
62/// Encapsulates the working directory and loop identity, providing
63/// consistent path resolution for all loop-local state files.
64#[derive(Debug, Clone)]
65pub struct LoopContext {
66    /// The loop identifier (None for primary loop).
67    loop_id: Option<String>,
68
69    /// Working directory for this loop.
70    /// For primary: the repo root.
71    /// For worktree: the worktree directory.
72    workspace: PathBuf,
73
74    /// The main repo root (for memory symlink target).
75    /// Same as workspace for primary loops.
76    repo_root: PathBuf,
77
78    /// Whether this is the primary loop (holds loop.lock).
79    is_primary: bool,
80}
81
82impl LoopContext {
83    /// Creates context for the primary loop running in the main workspace.
84    ///
85    /// The primary loop holds the loop lock and runs directly in the
86    /// repository root without filesystem isolation.
87    pub fn primary(workspace: PathBuf) -> Self {
88        Self {
89            loop_id: None,
90            repo_root: workspace.clone(),
91            workspace,
92            is_primary: true,
93        }
94    }
95
96    /// Creates context for a worktree-based loop.
97    ///
98    /// Worktree loops run in isolated git worktrees with their own
99    /// `.ralph/` directory, but share memories, specs, and code tasks via symlink.
100    ///
101    /// # Arguments
102    ///
103    /// * `loop_id` - Unique identifier for this loop (e.g., "loop-1234-abcd")
104    /// * `worktree_path` - Path to the worktree directory
105    /// * `repo_root` - Path to the main repository root (for symlinks)
106    pub fn worktree(
107        loop_id: impl Into<String>,
108        worktree_path: PathBuf,
109        repo_root: PathBuf,
110    ) -> Self {
111        Self {
112            loop_id: Some(loop_id.into()),
113            workspace: worktree_path,
114            repo_root,
115            is_primary: false,
116        }
117    }
118
119    /// Returns the loop identifier, if any.
120    ///
121    /// Primary loops return None; worktree loops return their unique ID.
122    pub fn loop_id(&self) -> Option<&str> {
123        self.loop_id.as_deref()
124    }
125
126    /// Returns true if this is the primary loop.
127    pub fn is_primary(&self) -> bool {
128        self.is_primary
129    }
130
131    /// Returns the workspace root for this loop.
132    ///
133    /// This is the directory where the loop executes:
134    /// - Primary: the repo root
135    /// - Worktree: the worktree directory
136    pub fn workspace(&self) -> &Path {
137        &self.workspace
138    }
139
140    /// Returns the main repository root.
141    ///
142    /// For worktree loops, this is different from `workspace()` and
143    /// is used to locate shared resources like the main memories file.
144    pub fn repo_root(&self) -> &Path {
145        &self.repo_root
146    }
147
148    // -------------------------------------------------------------------------
149    // Path resolution methods
150    // -------------------------------------------------------------------------
151
152    /// Path to the `.ralph/` directory for this loop.
153    pub fn ralph_dir(&self) -> PathBuf {
154        self.workspace.join(".ralph")
155    }
156
157    /// Path to the `.ralph/agent/` directory for this loop.
158    ///
159    /// This directory contains agent state: memories, tasks, scratchpad, etc.
160    pub fn agent_dir(&self) -> PathBuf {
161        self.ralph_dir().join("agent")
162    }
163
164    /// Path to the events JSONL file.
165    ///
166    /// Each loop has its own isolated events file.
167    pub fn events_path(&self) -> PathBuf {
168        self.ralph_dir().join("events.jsonl")
169    }
170
171    /// Path to the current-events marker file.
172    ///
173    /// This file contains the path to the active events file.
174    pub fn current_events_marker(&self) -> PathBuf {
175        self.ralph_dir().join("current-events")
176    }
177
178    /// Path to the tasks JSONL file.
179    ///
180    /// Each loop has its own isolated tasks file.
181    pub fn tasks_path(&self) -> PathBuf {
182        self.agent_dir().join("tasks.jsonl")
183    }
184
185    /// Path to the scratchpad markdown file.
186    ///
187    /// Each loop has its own isolated scratchpad.
188    pub fn scratchpad_path(&self) -> PathBuf {
189        self.agent_dir().join("scratchpad.md")
190    }
191
192    /// Path to the memories markdown file.
193    ///
194    /// For primary loops, this is the actual memories file.
195    /// For worktree loops, this is a symlink to the main repo's memories.
196    pub fn memories_path(&self) -> PathBuf {
197        self.agent_dir().join("memories.md")
198    }
199
200    /// Path to the main repository's memories file.
201    ///
202    /// Used to create symlinks in worktree loops.
203    pub fn main_memories_path(&self) -> PathBuf {
204        self.repo_root
205            .join(".ralph")
206            .join("agent")
207            .join("memories.md")
208    }
209
210    /// Path to the context markdown file.
211    ///
212    /// This file contains worktree metadata (loop ID, workspace, branch, etc.)
213    /// and is only created in worktree loops.
214    pub fn context_path(&self) -> PathBuf {
215        self.agent_dir().join("context.md")
216    }
217
218    /// Path to the specs directory for this loop.
219    ///
220    /// For primary loops, this is the actual specs directory.
221    /// For worktree loops, this is a symlink to the main repo's specs.
222    pub fn specs_dir(&self) -> PathBuf {
223        self.ralph_dir().join("specs")
224    }
225
226    /// Path to the code tasks directory for this loop.
227    ///
228    /// For primary loops, this is the actual code tasks directory.
229    /// For worktree loops, this is a symlink to the main repo's code tasks.
230    /// Note: This is different from tasks_path() which is for runtime task tracking.
231    pub fn code_tasks_dir(&self) -> PathBuf {
232        self.ralph_dir().join("tasks")
233    }
234
235    /// Path to the main repository's specs directory.
236    ///
237    /// Used to create symlinks in worktree loops.
238    pub fn main_specs_dir(&self) -> PathBuf {
239        self.repo_root.join(".ralph").join("specs")
240    }
241
242    /// Path to the main repository's code tasks directory.
243    ///
244    /// Used to create symlinks in worktree loops.
245    pub fn main_code_tasks_dir(&self) -> PathBuf {
246        self.repo_root.join(".ralph").join("tasks")
247    }
248
249    /// Path to the summary markdown file.
250    ///
251    /// Each loop has its own isolated summary.
252    pub fn summary_path(&self) -> PathBuf {
253        self.agent_dir().join("summary.md")
254    }
255
256    /// Path to the handoff markdown file.
257    ///
258    /// Generated on loop completion to provide context for the next session.
259    /// Contains completed tasks, remaining work, and a ready-to-paste prompt.
260    pub fn handoff_path(&self) -> PathBuf {
261        self.agent_dir().join("handoff.md")
262    }
263
264    /// Path to the diagnostics directory.
265    ///
266    /// Each loop has its own diagnostics output.
267    pub fn diagnostics_dir(&self) -> PathBuf {
268        self.ralph_dir().join("diagnostics")
269    }
270
271    /// Path to the loop history JSONL file.
272    ///
273    /// Event-sourced history for crash recovery and debugging.
274    pub fn history_path(&self) -> PathBuf {
275        self.ralph_dir().join("history.jsonl")
276    }
277
278    /// Path to the loop lock file (only meaningful for primary loop detection).
279    pub fn loop_lock_path(&self) -> PathBuf {
280        // Lock is always in the main repo root
281        self.repo_root.join(".ralph").join("loop.lock")
282    }
283
284    /// Path to the merge queue JSONL file.
285    ///
286    /// The merge queue is shared across all loops (in main repo).
287    pub fn merge_queue_path(&self) -> PathBuf {
288        self.repo_root.join(".ralph").join("merge-queue.jsonl")
289    }
290
291    /// Path to the loop registry JSON file.
292    ///
293    /// The registry is shared across all loops (in main repo).
294    pub fn loop_registry_path(&self) -> PathBuf {
295        self.repo_root.join(".ralph").join("loops.json")
296    }
297
298    /// Path to the planning sessions directory.
299    ///
300    /// Contains all planning session subdirectories.
301    pub fn planning_sessions_dir(&self) -> PathBuf {
302        self.ralph_dir().join("planning-sessions")
303    }
304
305    /// Path to a specific planning session directory.
306    ///
307    /// # Arguments
308    ///
309    /// * `id` - The session ID (e.g., "20260127-143022-a7f2")
310    pub fn planning_session_dir(&self, id: &str) -> PathBuf {
311        self.planning_sessions_dir().join(id)
312    }
313
314    /// Path to the conversation file for a planning session.
315    ///
316    /// # Arguments
317    ///
318    /// * `id` - The session ID
319    pub fn planning_conversation_path(&self, id: &str) -> PathBuf {
320        self.planning_session_dir(id).join("conversation.jsonl")
321    }
322
323    /// Path to the session metadata file for a planning session.
324    ///
325    /// # Arguments
326    ///
327    /// * `id` - The session ID
328    pub fn planning_session_metadata_path(&self, id: &str) -> PathBuf {
329        self.planning_session_dir(id).join("session.json")
330    }
331
332    /// Path to the artifacts directory for a planning session.
333    ///
334    /// # Arguments
335    ///
336    /// * `id` - The session ID
337    pub fn planning_artifacts_dir(&self, id: &str) -> PathBuf {
338        self.planning_session_dir(id).join("artifacts")
339    }
340
341    // -------------------------------------------------------------------------
342    // Directory management
343    // -------------------------------------------------------------------------
344
345    /// Ensures the `.ralph/` directory exists.
346    pub fn ensure_ralph_dir(&self) -> std::io::Result<()> {
347        std::fs::create_dir_all(self.ralph_dir())
348    }
349
350    /// Ensures the `.ralph/agent/` directory exists.
351    pub fn ensure_agent_dir(&self) -> std::io::Result<()> {
352        std::fs::create_dir_all(self.agent_dir())
353    }
354
355    /// Ensures the `.ralph/specs/` directory exists.
356    pub fn ensure_specs_dir(&self) -> std::io::Result<()> {
357        std::fs::create_dir_all(self.specs_dir())
358    }
359
360    /// Ensures the `.ralph/tasks/` directory exists.
361    pub fn ensure_code_tasks_dir(&self) -> std::io::Result<()> {
362        std::fs::create_dir_all(self.code_tasks_dir())
363    }
364
365    /// Ensures all required directories exist.
366    pub fn ensure_directories(&self) -> std::io::Result<()> {
367        self.ensure_ralph_dir()?;
368        self.ensure_agent_dir()?;
369        // Note: specs_dir and code_tasks_dir are optional (may be symlinks in worktrees)
370        Ok(())
371    }
372
373    /// Creates the memory symlink in a worktree pointing to main repo.
374    ///
375    /// This is only relevant for worktree loops. For primary loops,
376    /// this is a no-op.
377    ///
378    /// # Returns
379    ///
380    /// - `Ok(true)` - Symlink was created
381    /// - `Ok(false)` - Already exists or is primary loop
382    /// - `Err(_)` - Symlink creation failed
383    #[cfg(unix)]
384    pub fn setup_memory_symlink(&self) -> std::io::Result<bool> {
385        if self.is_primary {
386            return Ok(false);
387        }
388
389        let memories_path = self.memories_path();
390        let main_memories = self.main_memories_path();
391
392        // Skip if already exists (symlink or file)
393        if memories_path.exists() || memories_path.is_symlink() {
394            return Ok(false);
395        }
396
397        // Ensure parent directory exists
398        self.ensure_agent_dir()?;
399
400        // Create symlink
401        std::os::unix::fs::symlink(&main_memories, &memories_path)?;
402        Ok(true)
403    }
404
405    /// Creates the memory symlink in a worktree (non-Unix stub).
406    #[cfg(not(unix))]
407    pub fn setup_memory_symlink(&self) -> std::io::Result<bool> {
408        // On non-Unix platforms, we don't create symlinks
409        // (worktree mode not supported)
410        Ok(false)
411    }
412
413    /// Creates the specs symlink in a worktree pointing to main repo.
414    ///
415    /// This allows worktree loops to access specs from the main repo,
416    /// even when they are untracked (not committed to git).
417    ///
418    /// # Returns
419    ///
420    /// - `Ok(true)` - Symlink was created
421    /// - `Ok(false)` - Already exists or is primary loop
422    /// - `Err(_)` - Symlink creation failed
423    #[cfg(unix)]
424    pub fn setup_specs_symlink(&self) -> std::io::Result<bool> {
425        if self.is_primary {
426            return Ok(false);
427        }
428
429        let specs_path = self.specs_dir();
430        let main_specs = self.main_specs_dir();
431
432        // Skip if already exists (symlink or directory)
433        if specs_path.exists() || specs_path.is_symlink() {
434            return Ok(false);
435        }
436
437        // Ensure parent directory exists
438        self.ensure_ralph_dir()?;
439
440        // Create symlink
441        std::os::unix::fs::symlink(&main_specs, &specs_path)?;
442        Ok(true)
443    }
444
445    /// Creates the specs symlink in a worktree (non-Unix stub).
446    #[cfg(not(unix))]
447    pub fn setup_specs_symlink(&self) -> std::io::Result<bool> {
448        Ok(false)
449    }
450
451    /// Creates the code tasks symlink in a worktree pointing to main repo.
452    ///
453    /// This allows worktree loops to access code task files from the main repo,
454    /// even when they are untracked (not committed to git).
455    ///
456    /// # Returns
457    ///
458    /// - `Ok(true)` - Symlink was created
459    /// - `Ok(false)` - Already exists or is primary loop
460    /// - `Err(_)` - Symlink creation failed
461    #[cfg(unix)]
462    pub fn setup_code_tasks_symlink(&self) -> std::io::Result<bool> {
463        if self.is_primary {
464            return Ok(false);
465        }
466
467        let tasks_path = self.code_tasks_dir();
468        let main_tasks = self.main_code_tasks_dir();
469
470        // Skip if already exists (symlink or directory)
471        if tasks_path.exists() || tasks_path.is_symlink() {
472            return Ok(false);
473        }
474
475        // Ensure parent directory exists
476        self.ensure_ralph_dir()?;
477
478        // Create symlink
479        std::os::unix::fs::symlink(&main_tasks, &tasks_path)?;
480        Ok(true)
481    }
482
483    /// Creates the code tasks symlink in a worktree (non-Unix stub).
484    #[cfg(not(unix))]
485    pub fn setup_code_tasks_symlink(&self) -> std::io::Result<bool> {
486        Ok(false)
487    }
488
489    /// Generates a context.md file in the worktree with metadata.
490    ///
491    /// This file tells the agent it's running in a worktree and provides
492    /// information about the worktree context (loop ID, workspace, branch, etc.)
493    ///
494    /// # Arguments
495    ///
496    /// * `branch` - The git branch name for this worktree
497    /// * `prompt` - The prompt that started this loop
498    ///
499    /// # Returns
500    ///
501    /// - `Ok(true)` - Context file was created
502    /// - `Ok(false)` - Already exists or is primary loop
503    /// - `Err(_)` - File creation failed
504    pub fn generate_context_file(&self, branch: &str, prompt: &str) -> std::io::Result<bool> {
505        if self.is_primary {
506            return Ok(false);
507        }
508
509        let context_path = self.context_path();
510
511        // Skip if already exists
512        if context_path.exists() {
513            return Ok(false);
514        }
515
516        // Ensure parent directory exists
517        self.ensure_agent_dir()?;
518
519        let loop_id = self.loop_id().unwrap_or("unknown");
520        let created = chrono::Utc::now().to_rfc3339();
521
522        // Truncate prompt for readability
523        let prompt_preview = truncate_with_ellipsis(prompt, 200);
524
525        let content = format!(
526            r#"# Worktree Context
527
528- **Loop ID**: {}
529- **Workspace**: {}
530- **Main Repo**: {}
531- **Branch**: {}
532- **Created**: {}
533- **Prompt**: "{}"
534
535## Notes
536
537This is a worktree-based parallel loop. The following resources are symlinked
538to the main repository:
539
540- `.ralph/agent/memories.md` → shared memories
541- `.ralph/specs/` → shared specifications
542- `.ralph/tasks/` → shared code task files
543
544Local state (scratchpad, runtime tasks, events) is isolated to this worktree.
545"#,
546            loop_id,
547            self.workspace.display(),
548            self.repo_root.display(),
549            branch,
550            created,
551            prompt_preview
552        );
553
554        std::fs::write(&context_path, content)?;
555        Ok(true)
556    }
557
558    /// Sets up all worktree symlinks (memories, specs, code tasks).
559    ///
560    /// Convenience method that calls all setup_*_symlink methods.
561    /// Only relevant for worktree loops - no-op for primary loops.
562    #[cfg(unix)]
563    pub fn setup_worktree_symlinks(&self) -> std::io::Result<()> {
564        self.setup_memory_symlink()?;
565        self.setup_specs_symlink()?;
566        self.setup_code_tasks_symlink()?;
567        Ok(())
568    }
569
570    /// Sets up all worktree symlinks (non-Unix stub).
571    #[cfg(not(unix))]
572    pub fn setup_worktree_symlinks(&self) -> std::io::Result<()> {
573        Ok(())
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use tempfile::TempDir;
581
582    #[test]
583    fn test_primary_context() {
584        let ctx = LoopContext::primary(PathBuf::from("/project"));
585
586        assert!(ctx.is_primary());
587        assert!(ctx.loop_id().is_none());
588        assert_eq!(ctx.workspace(), Path::new("/project"));
589        assert_eq!(ctx.repo_root(), Path::new("/project"));
590    }
591
592    #[test]
593    fn test_worktree_context() {
594        let ctx = LoopContext::worktree(
595            "loop-1234-abcd",
596            PathBuf::from("/project/.worktrees/loop-1234-abcd"),
597            PathBuf::from("/project"),
598        );
599
600        assert!(!ctx.is_primary());
601        assert_eq!(ctx.loop_id(), Some("loop-1234-abcd"));
602        assert_eq!(
603            ctx.workspace(),
604            Path::new("/project/.worktrees/loop-1234-abcd")
605        );
606        assert_eq!(ctx.repo_root(), Path::new("/project"));
607    }
608
609    #[test]
610    fn test_primary_path_resolution() {
611        let ctx = LoopContext::primary(PathBuf::from("/project"));
612
613        assert_eq!(ctx.ralph_dir(), PathBuf::from("/project/.ralph"));
614        assert_eq!(ctx.agent_dir(), PathBuf::from("/project/.ralph/agent"));
615        assert_eq!(
616            ctx.events_path(),
617            PathBuf::from("/project/.ralph/events.jsonl")
618        );
619        assert_eq!(
620            ctx.tasks_path(),
621            PathBuf::from("/project/.ralph/agent/tasks.jsonl")
622        );
623        assert_eq!(
624            ctx.scratchpad_path(),
625            PathBuf::from("/project/.ralph/agent/scratchpad.md")
626        );
627        assert_eq!(
628            ctx.memories_path(),
629            PathBuf::from("/project/.ralph/agent/memories.md")
630        );
631        assert_eq!(
632            ctx.summary_path(),
633            PathBuf::from("/project/.ralph/agent/summary.md")
634        );
635        assert_eq!(
636            ctx.handoff_path(),
637            PathBuf::from("/project/.ralph/agent/handoff.md")
638        );
639        assert_eq!(ctx.specs_dir(), PathBuf::from("/project/.ralph/specs"));
640        assert_eq!(ctx.code_tasks_dir(), PathBuf::from("/project/.ralph/tasks"));
641        assert_eq!(
642            ctx.diagnostics_dir(),
643            PathBuf::from("/project/.ralph/diagnostics")
644        );
645        assert_eq!(
646            ctx.history_path(),
647            PathBuf::from("/project/.ralph/history.jsonl")
648        );
649    }
650
651    #[test]
652    fn test_worktree_path_resolution() {
653        let ctx = LoopContext::worktree(
654            "loop-1234-abcd",
655            PathBuf::from("/project/.worktrees/loop-1234-abcd"),
656            PathBuf::from("/project"),
657        );
658
659        // Loop-local paths resolve to worktree
660        assert_eq!(
661            ctx.ralph_dir(),
662            PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph")
663        );
664        assert_eq!(
665            ctx.agent_dir(),
666            PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent")
667        );
668        assert_eq!(
669            ctx.events_path(),
670            PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/events.jsonl")
671        );
672        assert_eq!(
673            ctx.tasks_path(),
674            PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent/tasks.jsonl")
675        );
676        assert_eq!(
677            ctx.scratchpad_path(),
678            PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent/scratchpad.md")
679        );
680
681        // Memories path is in worktree (symlink to main repo)
682        assert_eq!(
683            ctx.memories_path(),
684            PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent/memories.md")
685        );
686
687        // Main memories path is in repo root
688        assert_eq!(
689            ctx.main_memories_path(),
690            PathBuf::from("/project/.ralph/agent/memories.md")
691        );
692
693        // Specs and code tasks paths (symlinks to main repo)
694        assert_eq!(
695            ctx.specs_dir(),
696            PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/specs")
697        );
698        assert_eq!(
699            ctx.code_tasks_dir(),
700            PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/tasks")
701        );
702        assert_eq!(ctx.main_specs_dir(), PathBuf::from("/project/.ralph/specs"));
703        assert_eq!(
704            ctx.main_code_tasks_dir(),
705            PathBuf::from("/project/.ralph/tasks")
706        );
707
708        // Shared resources resolve to main repo
709        assert_eq!(
710            ctx.loop_lock_path(),
711            PathBuf::from("/project/.ralph/loop.lock")
712        );
713        assert_eq!(
714            ctx.merge_queue_path(),
715            PathBuf::from("/project/.ralph/merge-queue.jsonl")
716        );
717        assert_eq!(
718            ctx.loop_registry_path(),
719            PathBuf::from("/project/.ralph/loops.json")
720        );
721    }
722
723    #[test]
724    fn test_ensure_directories() {
725        let temp = TempDir::new().unwrap();
726        let ctx = LoopContext::primary(temp.path().to_path_buf());
727
728        // Directories don't exist initially
729        assert!(!ctx.ralph_dir().exists());
730        assert!(!ctx.agent_dir().exists());
731
732        // Create them
733        ctx.ensure_directories().unwrap();
734
735        // Now they exist
736        assert!(ctx.ralph_dir().exists());
737        assert!(ctx.agent_dir().exists());
738    }
739
740    #[cfg(unix)]
741    #[test]
742    fn test_memory_symlink_primary_noop() {
743        let temp = TempDir::new().unwrap();
744        let ctx = LoopContext::primary(temp.path().to_path_buf());
745
746        // Primary loop doesn't create symlinks
747        let created = ctx.setup_memory_symlink().unwrap();
748        assert!(!created);
749    }
750
751    #[cfg(unix)]
752    #[test]
753    fn test_memory_symlink_worktree() {
754        let temp = TempDir::new().unwrap();
755        let repo_root = temp.path().to_path_buf();
756        let worktree_path = repo_root.join(".worktrees/loop-1234");
757
758        // Create the main memories file under .ralph/agent/
759        std::fs::create_dir_all(repo_root.join(".ralph/agent")).unwrap();
760        std::fs::write(repo_root.join(".ralph/agent/memories.md"), "# Memories\n").unwrap();
761
762        let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
763
764        // Create symlink
765        ctx.ensure_agent_dir().unwrap();
766        let created = ctx.setup_memory_symlink().unwrap();
767        assert!(created);
768
769        // Verify symlink exists and points to main memories
770        let memories = ctx.memories_path();
771        assert!(memories.is_symlink());
772        assert_eq!(
773            std::fs::read_link(&memories).unwrap(),
774            ctx.main_memories_path()
775        );
776
777        // Second call is a no-op
778        let created_again = ctx.setup_memory_symlink().unwrap();
779        assert!(!created_again);
780    }
781
782    #[test]
783    fn test_current_events_marker() {
784        let ctx = LoopContext::primary(PathBuf::from("/project"));
785        assert_eq!(
786            ctx.current_events_marker(),
787            PathBuf::from("/project/.ralph/current-events")
788        );
789    }
790
791    #[test]
792    fn test_planning_sessions_paths() {
793        let ctx = LoopContext::primary(PathBuf::from("/project"));
794
795        assert_eq!(
796            ctx.planning_sessions_dir(),
797            PathBuf::from("/project/.ralph/planning-sessions")
798        );
799    }
800
801    #[test]
802    fn test_planning_session_paths() {
803        let ctx = LoopContext::primary(PathBuf::from("/project"));
804        let session_id = "20260127-143022-a7f2";
805
806        assert_eq!(
807            ctx.planning_session_dir(session_id),
808            PathBuf::from("/project/.ralph/planning-sessions/20260127-143022-a7f2")
809        );
810        assert_eq!(
811            ctx.planning_conversation_path(session_id),
812            PathBuf::from(
813                "/project/.ralph/planning-sessions/20260127-143022-a7f2/conversation.jsonl"
814            )
815        );
816        assert_eq!(
817            ctx.planning_session_metadata_path(session_id),
818            PathBuf::from("/project/.ralph/planning-sessions/20260127-143022-a7f2/session.json")
819        );
820        assert_eq!(
821            ctx.planning_artifacts_dir(session_id),
822            PathBuf::from("/project/.ralph/planning-sessions/20260127-143022-a7f2/artifacts")
823        );
824    }
825
826    #[test]
827    fn test_planning_session_directory_creation() {
828        let temp = TempDir::new().unwrap();
829        let ctx = LoopContext::primary(temp.path().to_path_buf());
830        let session_id = "test-session";
831
832        // Create session directory
833        std::fs::create_dir_all(ctx.planning_session_dir(session_id)).unwrap();
834
835        // Verify directory exists
836        assert!(ctx.planning_session_dir(session_id).exists());
837        assert!(ctx.planning_sessions_dir().exists());
838    }
839
840    #[test]
841    fn test_context_path() {
842        let ctx = LoopContext::primary(PathBuf::from("/project"));
843        assert_eq!(
844            ctx.context_path(),
845            PathBuf::from("/project/.ralph/agent/context.md")
846        );
847    }
848
849    #[cfg(unix)]
850    #[test]
851    fn test_specs_symlink_worktree() {
852        let temp = TempDir::new().unwrap();
853        let repo_root = temp.path().to_path_buf();
854        let worktree_path = repo_root.join(".worktrees/loop-1234");
855
856        // Create the main specs directory
857        std::fs::create_dir_all(repo_root.join(".ralph/specs")).unwrap();
858        std::fs::write(repo_root.join(".ralph/specs/test.spec.md"), "# Test Spec\n").unwrap();
859
860        let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
861
862        // Ensure .ralph dir exists
863        ctx.ensure_ralph_dir().unwrap();
864
865        // Create symlink
866        let created = ctx.setup_specs_symlink().unwrap();
867        assert!(created);
868
869        // Verify symlink exists and points to main specs
870        let specs = ctx.specs_dir();
871        assert!(specs.is_symlink());
872        assert_eq!(std::fs::read_link(&specs).unwrap(), ctx.main_specs_dir());
873
874        // Second call is a no-op
875        let created_again = ctx.setup_specs_symlink().unwrap();
876        assert!(!created_again);
877    }
878
879    #[cfg(unix)]
880    #[test]
881    fn test_code_tasks_symlink_worktree() {
882        let temp = TempDir::new().unwrap();
883        let repo_root = temp.path().to_path_buf();
884        let worktree_path = repo_root.join(".worktrees/loop-1234");
885
886        // Create the main code tasks directory
887        std::fs::create_dir_all(repo_root.join(".ralph/tasks")).unwrap();
888        std::fs::write(
889            repo_root.join(".ralph/tasks/test.code-task.md"),
890            "# Test Task\n",
891        )
892        .unwrap();
893
894        let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
895
896        // Ensure .ralph dir exists
897        ctx.ensure_ralph_dir().unwrap();
898
899        // Create symlink
900        let created = ctx.setup_code_tasks_symlink().unwrap();
901        assert!(created);
902
903        // Verify symlink exists and points to main code tasks
904        let tasks = ctx.code_tasks_dir();
905        assert!(tasks.is_symlink());
906        assert_eq!(
907            std::fs::read_link(&tasks).unwrap(),
908            ctx.main_code_tasks_dir()
909        );
910
911        // Second call is a no-op
912        let created_again = ctx.setup_code_tasks_symlink().unwrap();
913        assert!(!created_again);
914    }
915
916    #[test]
917    fn test_generate_context_file_primary_noop() {
918        let temp = TempDir::new().unwrap();
919        let ctx = LoopContext::primary(temp.path().to_path_buf());
920
921        // Primary loop doesn't create context file
922        let created = ctx.generate_context_file("main", "test prompt").unwrap();
923        assert!(!created);
924    }
925
926    #[test]
927    fn test_generate_context_file_worktree() {
928        let temp = TempDir::new().unwrap();
929        let repo_root = temp.path().to_path_buf();
930        let worktree_path = repo_root.join(".worktrees/loop-1234");
931
932        let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
933
934        // Create context file
935        ctx.ensure_agent_dir().unwrap();
936        let created = ctx
937            .generate_context_file("ralph/loop-1234", "Add footer")
938            .unwrap();
939        assert!(created);
940
941        // Verify file exists and contains expected content
942        let context_path = ctx.context_path();
943        assert!(context_path.exists());
944
945        let content = std::fs::read_to_string(&context_path).unwrap();
946        assert!(content.contains("# Worktree Context"));
947        assert!(content.contains("loop-1234"));
948        assert!(content.contains("Add footer"));
949        assert!(content.contains("ralph/loop-1234"));
950
951        // Second call is a no-op
952        let created_again = ctx
953            .generate_context_file("ralph/loop-1234", "Add footer")
954            .unwrap();
955        assert!(!created_again);
956    }
957
958    #[cfg(unix)]
959    #[test]
960    fn test_setup_worktree_symlinks() {
961        let temp = TempDir::new().unwrap();
962        let repo_root = temp.path().to_path_buf();
963        let worktree_path = repo_root.join(".worktrees/loop-1234");
964
965        // Create main repo directories
966        std::fs::create_dir_all(repo_root.join(".ralph/agent")).unwrap();
967        std::fs::create_dir_all(repo_root.join(".ralph/specs")).unwrap();
968        std::fs::create_dir_all(repo_root.join(".ralph/tasks")).unwrap();
969        std::fs::write(repo_root.join(".ralph/agent/memories.md"), "# Memories\n").unwrap();
970
971        let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
972
973        // Setup all symlinks
974        ctx.ensure_directories().unwrap();
975        ctx.setup_worktree_symlinks().unwrap();
976
977        // Verify all symlinks exist
978        assert!(ctx.memories_path().is_symlink());
979        assert!(ctx.specs_dir().is_symlink());
980        assert!(ctx.code_tasks_dir().is_symlink());
981    }
982}