Skip to main content

ralph/commands/
init.rs

1//! Initialization workflow for creating `.ralph` state and starter files.
2//!
3//! Responsibilities:
4//! - Orchestrate initialization via `run_init()`.
5//! - Provide public types for initialization options and results.
6//! - Re-export submodule functionality for CLI layer.
7//! - Update `.gitignore` to include `.ralph/workspaces/` for parallel mode hygiene.
8//!
9//! Submodules:
10//! - `readme`: README version management and updates.
11//! - `wizard`: Interactive onboarding wizard UI.
12//! - `writers`: File creation for queue, done, and config.
13//! - `gitignore`: Gitignore update for Ralph workspace directories.
14//!
15//! Not handled here:
16//! - CLI argument parsing (see `crate::cli::init`).
17//! - TTY detection (handled by CLI layer).
18//!
19//! Invariants/assumptions:
20//! - Wizard answers are validated before file creation.
21//! - Non-interactive mode produces identical output to pre-wizard behavior.
22//! - Gitignore updates are idempotent (safe to run multiple times).
23
24use crate::config;
25use crate::queue;
26use anyhow::{Context, Result};
27use colored::Colorize;
28use std::fs;
29
30pub mod gitignore;
31pub mod readme;
32pub mod wizard;
33pub mod writers;
34
35// Re-export public items from submodules
36pub use readme::{
37    ReadmeCheckResult, ReadmeVersionError, check_readme_current, check_readme_current_from_root,
38    extract_readme_version,
39};
40
41// Re-export README_VERSION from constants for backward compatibility
42pub use crate::constants::versions::README_VERSION;
43pub use wizard::{WizardAnswers, print_completion_message, run_wizard};
44pub use writers::{write_config, write_done, write_queue};
45
46/// Options for initializing Ralph files.
47pub struct InitOptions {
48    /// Overwrite existing files if they already exist.
49    pub force: bool,
50    /// Force remove stale locks.
51    pub force_lock: bool,
52    /// Run interactive onboarding wizard.
53    pub interactive: bool,
54    /// Update README if it exists (force overwrite with latest template).
55    pub update_readme: bool,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum FileInitStatus {
60    Created,
61    Valid,
62    Updated,
63}
64
65#[derive(Debug)]
66pub struct InitReport {
67    pub queue_status: FileInitStatus,
68    pub done_status: FileInitStatus,
69    pub config_status: FileInitStatus,
70    /// (status, version) tuple - version is Some if README was read/created
71    pub readme_status: Option<(FileInitStatus, Option<u32>)>,
72    /// Paths that were actually used for file creation (may differ from resolved paths)
73    pub queue_path: std::path::PathBuf,
74    pub done_path: std::path::PathBuf,
75    pub config_path: std::path::PathBuf,
76}
77
78pub fn run_init(resolved: &config::Resolved, opts: InitOptions) -> Result<InitReport> {
79    let ralph_dir = resolved.repo_root.join(".ralph");
80    fs::create_dir_all(&ralph_dir).with_context(|| format!("create {}", ralph_dir.display()))?;
81
82    let _queue_lock = queue::acquire_queue_lock(&resolved.repo_root, "init", opts.force_lock)?;
83
84    // Run wizard if interactive mode is enabled
85    let wizard_answers = if opts.interactive {
86        Some(wizard::run_wizard()?)
87    } else {
88        None
89    };
90
91    // For new projects, always use .jsonc extensions (don't fall back to .json)
92    let queue_path = resolved
93        .repo_root
94        .join(crate::constants::queue::DEFAULT_QUEUE_FILE);
95    let done_path = resolved
96        .repo_root
97        .join(crate::constants::queue::DEFAULT_DONE_FILE);
98    let config_path = resolved
99        .repo_root
100        .join(crate::constants::queue::DEFAULT_CONFIG_FILE);
101
102    let queue_status = writers::write_queue(
103        &queue_path,
104        opts.force,
105        &resolved.id_prefix,
106        resolved.id_width,
107        wizard_answers.as_ref(),
108    )?;
109    let done_status = writers::write_done(
110        &done_path,
111        opts.force,
112        &resolved.id_prefix,
113        resolved.id_width,
114    )?;
115    let config_status = writers::write_config(&config_path, opts.force, wizard_answers.as_ref())?;
116
117    let mut readme_status = None;
118    if crate::prompts::prompts_reference_readme(&resolved.repo_root)? {
119        let readme_path = resolved.repo_root.join(".ralph/README.md");
120        let (status, version) = readme::write_readme(&readme_path, opts.force, opts.update_readme)?;
121        readme_status = Some((status, version));
122    }
123
124    // Update .gitignore to include .ralph/workspaces/ for parallel mode hygiene
125    // This is idempotent - safe to run multiple times
126    if let Err(e) = gitignore::ensure_ralph_gitignore_entries(&resolved.repo_root) {
127        log::warn!(
128            "Failed to update .gitignore: {}. You may need to manually add '.ralph/workspaces/' to your .gitignore.",
129            e
130        );
131    }
132
133    // Check for pending migrations and warn if any exist
134    check_pending_migrations(resolved)?;
135
136    // Print completion message for interactive mode
137    if opts.interactive {
138        wizard::print_completion_message(wizard_answers.as_ref(), &resolved.queue_path);
139    }
140
141    Ok(InitReport {
142        queue_status,
143        done_status,
144        config_status,
145        readme_status,
146        queue_path,
147        done_path,
148        config_path,
149    })
150}
151
152/// Check for pending migrations and display a warning if any exist.
153fn check_pending_migrations(resolved: &config::Resolved) -> anyhow::Result<()> {
154    use crate::migration::{self, MigrationCheckResult};
155
156    let ctx = match migration::MigrationContext::from_resolved(resolved) {
157        Ok(ctx) => ctx,
158        Err(e) => {
159            log::debug!("Could not create migration context: {}", e);
160            return Ok(());
161        }
162    };
163
164    match migration::check_migrations(&ctx)? {
165        MigrationCheckResult::Current => {
166            // No migrations pending, nothing to do
167        }
168        MigrationCheckResult::Pending(migrations) => {
169            eprintln!();
170            eprintln!(
171                "{}",
172                format!("⚠ Warning: {} migration(s) pending", migrations.len()).yellow()
173            );
174            eprintln!("Run {} to apply them.", "ralph migrate --apply".cyan());
175        }
176    }
177
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::contracts::{Config, ProjectType};
185    use tempfile::TempDir;
186
187    fn resolved_for(dir: &TempDir) -> config::Resolved {
188        let repo_root = dir.path().to_path_buf();
189        let queue_path = repo_root.join(".ralph/queue.jsonc");
190        let done_path = repo_root.join(".ralph/done.jsonc");
191        let project_config_path = Some(repo_root.join(".ralph/config.jsonc"));
192        config::Resolved {
193            config: Config::default(),
194            repo_root,
195            queue_path,
196            done_path,
197            id_prefix: "RQ".to_string(),
198            id_width: 4,
199            global_config_path: None,
200            project_config_path,
201        }
202    }
203
204    #[test]
205    fn init_creates_missing_files() -> Result<()> {
206        let dir = TempDir::new()?;
207        let resolved = resolved_for(&dir);
208        let report = run_init(
209            &resolved,
210            InitOptions {
211                force: false,
212                force_lock: false,
213                interactive: false,
214                update_readme: false,
215            },
216        )?;
217        assert_eq!(report.queue_status, FileInitStatus::Created);
218        assert_eq!(report.done_status, FileInitStatus::Created);
219        assert_eq!(report.config_status, FileInitStatus::Created);
220        assert!(matches!(
221            report.readme_status,
222            Some((FileInitStatus::Created, Some(6)))
223        ));
224        let queue = crate::queue::load_queue(&resolved.queue_path)?;
225        assert_eq!(queue.version, 1);
226        let done = crate::queue::load_queue(&resolved.done_path)?;
227        assert_eq!(done.version, 1);
228        let raw_cfg = std::fs::read_to_string(resolved.project_config_path.as_ref().unwrap())?;
229        let cfg: Config = serde_json::from_str(&raw_cfg)?;
230        assert_eq!(cfg.version, 1);
231        let readme_path = resolved.repo_root.join(".ralph/README.md");
232        assert!(readme_path.exists());
233        let readme_raw = std::fs::read_to_string(readme_path)?;
234        assert!(readme_raw.contains("# Ralph runtime files"));
235        Ok(())
236    }
237
238    #[test]
239    fn init_generates_readme_with_correct_archive_command() -> Result<()> {
240        let dir = TempDir::new()?;
241        let resolved = resolved_for(&dir);
242        run_init(
243            &resolved,
244            InitOptions {
245                force: false,
246                force_lock: false,
247                interactive: false,
248                update_readme: false,
249            },
250        )?;
251        let readme_path = resolved.repo_root.join(".ralph/README.md");
252        let readme_raw = std::fs::read_to_string(readme_path)?;
253        // Verify the correct command is present
254        assert!(
255            readme_raw.contains("ralph queue archive"),
256            "README should contain 'ralph queue archive' command"
257        );
258        // Verify the stale command is NOT present (regression check)
259        assert!(
260            !readme_raw.contains("ralph queue done"),
261            "README should NOT contain stale 'ralph queue done' command"
262        );
263        Ok(())
264    }
265
266    #[test]
267    fn init_skips_existing_when_not_forced() -> Result<()> {
268        let dir = TempDir::new()?;
269        let resolved = resolved_for(&dir);
270        std::fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
271        let queue_json = r#"{
272  "version": 1,
273  "tasks": [
274    {
275      "id": "RQ-0001",
276      "status": "todo",
277      "title": "Keep",
278      "tags": ["code"],
279      "scope": ["x"],
280      "evidence": ["y"],
281      "plan": ["z"],
282      "request": "test",
283      "created_at": "2026-01-18T00:00:00Z",
284      "updated_at": "2026-01-18T00:00:00Z"
285    }
286  ]
287}"#;
288        std::fs::write(&resolved.queue_path, queue_json)?;
289        let done_json = r#"{
290  "version": 1,
291  "tasks": [
292    {
293      "id": "RQ-0002",
294      "status": "done",
295      "title": "Done",
296      "tags": ["code"],
297      "scope": ["x"],
298      "evidence": ["y"],
299      "plan": ["z"],
300      "request": "test",
301      "created_at": "2026-01-18T00:00:00Z",
302      "updated_at": "2026-01-18T00:00:00Z",
303      "completed_at": "2026-01-18T00:00:00Z"
304    }
305  ]
306}"#;
307        std::fs::write(&resolved.done_path, done_json)?;
308        let config_json = r#"{
309  "version": 1,
310  "queue": {
311    "file": ".ralph/queue.json"
312  }
313}"#;
314        std::fs::write(resolved.project_config_path.as_ref().unwrap(), config_json)?;
315        let report = run_init(
316            &resolved,
317            InitOptions {
318                force: false,
319                force_lock: false,
320                interactive: false,
321                update_readme: false,
322            },
323        )?;
324        assert_eq!(report.queue_status, FileInitStatus::Valid);
325        assert_eq!(report.done_status, FileInitStatus::Valid);
326        assert_eq!(report.config_status, FileInitStatus::Valid);
327        assert!(matches!(
328            report.readme_status,
329            Some((FileInitStatus::Created, Some(6)))
330        ));
331        let raw = std::fs::read_to_string(&resolved.queue_path)?;
332        assert!(raw.contains("Keep"));
333        let done_raw = std::fs::read_to_string(&resolved.done_path)?;
334        assert!(done_raw.contains("Done"));
335        Ok(())
336    }
337
338    #[test]
339    fn init_overwrites_when_forced() -> Result<()> {
340        let dir = TempDir::new()?;
341        let resolved = resolved_for(&dir);
342        std::fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
343        std::fs::write(&resolved.queue_path, r#"{"version":1,"tasks":[]}"#)?;
344        std::fs::write(&resolved.done_path, r#"{"version":1,"tasks":[]}"#)?;
345        std::fs::write(
346            resolved.project_config_path.as_ref().unwrap(),
347            r#"{"version":1,"project_type":"docs"}"#,
348        )?;
349        let report = run_init(
350            &resolved,
351            InitOptions {
352                force: true,
353                force_lock: false,
354                interactive: false,
355                update_readme: false,
356            },
357        )?;
358        assert_eq!(report.queue_status, FileInitStatus::Created);
359        assert_eq!(report.done_status, FileInitStatus::Created);
360        assert_eq!(report.config_status, FileInitStatus::Created);
361        assert!(matches!(
362            report.readme_status,
363            Some((FileInitStatus::Created, Some(6)))
364        ));
365        let cfg_raw = std::fs::read_to_string(resolved.project_config_path.as_ref().unwrap())?;
366        let cfg: Config = serde_json::from_str(&cfg_raw)?;
367        assert_eq!(cfg.project_type, Some(ProjectType::Code));
368        assert_eq!(
369            cfg.queue.file,
370            Some(std::path::PathBuf::from(".ralph/queue.jsonc"))
371        );
372        assert_eq!(
373            cfg.queue.done_file,
374            Some(std::path::PathBuf::from(".ralph/done.jsonc"))
375        );
376        assert_eq!(cfg.queue.id_prefix, Some("RQ".to_string()));
377        assert_eq!(cfg.queue.id_width, Some(4));
378        assert_eq!(cfg.agent.runner, Some(crate::contracts::Runner::Codex));
379        assert_eq!(cfg.agent.model, Some(crate::contracts::Model::Gpt54));
380        assert_eq!(
381            cfg.agent.reasoning_effort,
382            Some(crate::contracts::ReasoningEffort::Medium)
383        );
384        assert_eq!(cfg.agent.iterations, Some(1));
385        assert_eq!(cfg.agent.followup_reasoning_effort, None);
386        assert_eq!(cfg.agent.gemini_bin, Some("gemini".to_string()));
387        Ok(())
388    }
389
390    #[test]
391    fn init_creates_json_for_new_install() -> Result<()> {
392        let dir = TempDir::new()?;
393        let resolved = resolved_for(&dir);
394        let report = run_init(
395            &resolved,
396            InitOptions {
397                force: false,
398                force_lock: false,
399                interactive: false,
400                update_readme: false,
401            },
402        )?;
403        assert_eq!(report.queue_status, FileInitStatus::Created);
404        assert_eq!(report.done_status, FileInitStatus::Created);
405        assert_eq!(report.config_status, FileInitStatus::Created);
406
407        // Verify JSON files were created
408        let queue_raw = std::fs::read_to_string(&resolved.queue_path)?;
409        assert!(queue_raw.contains("{"));
410        let done_raw = std::fs::read_to_string(&resolved.done_path)?;
411        assert!(done_raw.contains("{"));
412        let cfg_raw = std::fs::read_to_string(resolved.project_config_path.as_ref().unwrap())?;
413        assert!(cfg_raw.contains("{"));
414        Ok(())
415    }
416
417    #[test]
418    fn init_skips_readme_when_not_referenced() -> Result<()> {
419        let dir = TempDir::new()?;
420        let resolved = resolved_for(&dir);
421
422        // Override all prompts to ensure none reference the README.
423        let overrides = resolved.repo_root.join(".ralph/prompts");
424        fs::create_dir_all(&overrides)?;
425        let prompt_files = [
426            "worker.md",
427            "worker_phase1.md",
428            "worker_phase2.md",
429            "worker_phase2_handoff.md",
430            "worker_phase3.md",
431            "worker_single_phase.md",
432            "task_builder.md",
433            "task_updater.md",
434            "scan.md",
435            "completion_checklist.md",
436            "code_review.md",
437            "phase2_handoff_checklist.md",
438            "iteration_checklist.md",
439        ];
440        for file in prompt_files {
441            fs::write(overrides.join(file), "no reference")?;
442        }
443
444        let report = run_init(
445            &resolved,
446            InitOptions {
447                force: false,
448                force_lock: false,
449                interactive: false,
450                update_readme: false,
451            },
452        )?;
453        assert_eq!(report.readme_status, None);
454        let readme_path = resolved.repo_root.join(".ralph/README.md");
455        assert!(!readme_path.exists());
456        Ok(())
457    }
458
459    #[test]
460    fn init_fails_on_invalid_existing_queue() -> Result<()> {
461        let dir = TempDir::new()?;
462        let resolved = resolved_for(&dir);
463        std::fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
464
465        // Create a queue with an invalid ID prefix (WRONG-0001 vs RQ)
466        let queue_json = r#"{
467  "version": 1,
468  "tasks": [
469    {
470      "id": "WRONG-0001",
471      "status": "todo",
472      "title": "Bad ID",
473      "tags": [],
474      "scope": [],
475      "evidence": [],
476      "plan": [],
477      "request": "test",
478      "created_at": "2026-01-18T00:00:00Z",
479      "updated_at": "2026-01-18T00:00:00Z"
480    }
481  ]
482}"#;
483        std::fs::write(&resolved.queue_path, queue_json)?;
484        std::fs::write(&resolved.done_path, r#"{"version":1,"tasks":[]}"#)?;
485        std::fs::write(
486            resolved.project_config_path.as_ref().unwrap(),
487            r#"{"version":1,"project_type":"code"}"#,
488        )?;
489
490        let result = run_init(
491            &resolved,
492            InitOptions {
493                force: false,
494                force_lock: false,
495                interactive: false,
496                update_readme: false,
497            },
498        );
499
500        assert!(result.is_err());
501        let err = result.unwrap_err();
502        assert!(err.to_string().contains("validate existing queue"));
503        Ok(())
504    }
505
506    #[test]
507    fn init_fails_on_invalid_existing_done() -> Result<()> {
508        let dir = TempDir::new()?;
509        let resolved = resolved_for(&dir);
510        std::fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
511
512        std::fs::write(&resolved.queue_path, r#"{"version":1,"tasks":[]}"#)?;
513
514        // Create a done file with a task that has invalid ID prefix
515        let done_json = r#"{
516  "version": 1,
517  "tasks": [
518    {
519      "id": "WRONG-0002",
520      "status": "done",
521      "title": "Bad ID",
522      "tags": [],
523      "scope": [],
524      "evidence": [],
525      "plan": [],
526      "request": "test",
527      "created_at": "2026-01-18T00:00:00Z",
528      "updated_at": "2026-01-18T00:00:00Z"
529    }
530  ]
531}"#;
532        std::fs::write(&resolved.done_path, done_json)?;
533        std::fs::write(
534            resolved.project_config_path.as_ref().unwrap(),
535            r#"{"version":1,"project_type":"code"}"#,
536        )?;
537
538        let result = run_init(
539            &resolved,
540            InitOptions {
541                force: false,
542                force_lock: false,
543                interactive: false,
544                update_readme: false,
545            },
546        );
547
548        assert!(result.is_err());
549        let err = result.unwrap_err();
550        assert!(err.to_string().contains("validate existing done"));
551        Ok(())
552    }
553
554    #[test]
555    fn init_with_wizard_answers_creates_configured_files() -> Result<()> {
556        let dir = TempDir::new()?;
557        let resolved = resolved_for(&dir);
558
559        let wizard_answers = WizardAnswers {
560            runner: crate::contracts::Runner::Codex,
561            model: "gpt-5.4".to_string(),
562            phases: 2,
563            create_first_task: true,
564            first_task_title: Some("Test task".to_string()),
565            first_task_description: Some("Test description".to_string()),
566            first_task_priority: crate::contracts::TaskPriority::High,
567        };
568
569        let report = run_init(
570            &resolved,
571            InitOptions {
572                force: false,
573                force_lock: false,
574                interactive: false,
575                update_readme: false,
576            },
577        )?;
578
579        // Manually write the queue with wizard answers to test the write_queue function
580        writers::write_queue(
581            &resolved.queue_path,
582            true,
583            &resolved.id_prefix,
584            resolved.id_width,
585            Some(&wizard_answers),
586        )?;
587
588        writers::write_config(
589            resolved.project_config_path.as_ref().unwrap(),
590            true,
591            Some(&wizard_answers),
592        )?;
593
594        assert_eq!(report.done_status, FileInitStatus::Created);
595
596        // Verify config has correct runner and phases
597        let cfg_raw = std::fs::read_to_string(resolved.project_config_path.as_ref().unwrap())?;
598        let cfg: Config = serde_json::from_str(&cfg_raw)?;
599        assert_eq!(cfg.agent.runner, Some(crate::contracts::Runner::Codex));
600        assert_eq!(cfg.agent.phases, Some(2));
601
602        // Verify queue has first task
603        let queue = crate::queue::load_queue(&resolved.queue_path)?;
604        assert_eq!(queue.tasks.len(), 1);
605        assert_eq!(queue.tasks[0].title, "Test task");
606        assert_eq!(
607            queue.tasks[0].priority,
608            crate::contracts::TaskPriority::High
609        );
610
611        Ok(())
612    }
613
614    #[test]
615    fn init_update_readme_flag_updates_outdated() -> Result<()> {
616        let dir = TempDir::new()?;
617        let resolved = resolved_for(&dir);
618
619        // Create an existing README with old version
620        fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
621        let old_readme = "<!-- RALPH_README_VERSION: 1 -->\n# Old content";
622        fs::write(resolved.repo_root.join(".ralph/README.md"), old_readme)?;
623        fs::write(&resolved.queue_path, r#"{"version":1,"tasks":[]}"#)?;
624        fs::write(&resolved.done_path, r#"{"version":1,"tasks":[]}"#)?;
625        fs::write(
626            resolved.project_config_path.as_ref().unwrap(),
627            r#"{"version":1}"#,
628        )?;
629
630        let report = run_init(
631            &resolved,
632            InitOptions {
633                force: false,
634                force_lock: false,
635                interactive: false,
636                update_readme: true,
637            },
638        )?;
639
640        // README should be updated
641        assert!(matches!(
642            report.readme_status,
643            Some((FileInitStatus::Updated, Some(6)))
644        ));
645
646        // Content should be new
647        let content = std::fs::read_to_string(resolved.repo_root.join(".ralph/README.md"))?;
648        assert!(!content.contains("Old content"));
649        assert!(content.contains("Ralph runtime files"));
650        Ok(())
651    }
652}