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