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