1use 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
35pub use readme::{
37 ReadmeCheckResult, ReadmeVersionError, check_readme_current, check_readme_current_from_root,
38 extract_readme_version,
39};
40
41pub 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
46pub struct InitOptions {
48 pub force: bool,
50 pub force_lock: bool,
52 pub interactive: bool,
54 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 pub readme_status: Option<(FileInitStatus, Option<u32>)>,
72 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 let wizard_answers = if opts.interactive {
86 Some(wizard::run_wizard()?)
87 } else {
88 None
89 };
90
91 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 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_pending_migrations(resolved)?;
135
136 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
152fn 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 }
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 assert!(
255 readme_raw.contains("ralph queue archive"),
256 "README should contain 'ralph queue archive' command"
257 );
258 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 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 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 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 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 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 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 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 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 assert!(matches!(
642 report.readme_status,
643 Some((FileInitStatus::Updated, Some(6)))
644 ));
645
646 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}