Skip to main content

ralph/commands/init/
writers.rs

1//! File creation utilities for Ralph initialization.
2//!
3//! Responsibilities:
4//! - Create and write queue.jsonc, done.jsonc, and config.jsonc files.
5//! - Validate existing files when not forcing overwrite.
6//! - Integrate wizard answers for initial task and config values.
7//!
8//! Not handled here:
9//! - README file creation (see `super::readme`).
10//! - Interactive user input (see `super::wizard`).
11//!
12//! Invariants/assumptions:
13//! - Parent directories are created as needed.
14//! - Existing files are validated before being considered "Valid".
15//! - Atomic writes are used for all file operations.
16
17use crate::contracts::{QueueFile, Task, TaskStatus};
18use crate::fsutil;
19use crate::queue;
20use anyhow::{Context, Result};
21use std::fs;
22use std::path::Path;
23
24use super::FileInitStatus;
25use super::wizard::WizardAnswers;
26
27/// Write queue file, optionally including a first task from wizard answers.
28pub fn write_queue(
29    path: &Path,
30    force: bool,
31    id_prefix: &str,
32    id_width: usize,
33    wizard_answers: Option<&WizardAnswers>,
34) -> Result<FileInitStatus> {
35    if path.exists() && !force {
36        // Validate existing file by trying to load it
37        let queue = queue::load_queue(path)?;
38        queue::validate_queue(&queue, id_prefix, id_width)
39            .with_context(|| format!("validate existing queue {}", path.display()))?;
40        return Ok(FileInitStatus::Valid);
41    }
42    if let Some(parent) = path.parent() {
43        fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
44    }
45
46    let mut queue = QueueFile::default();
47
48    // Add first task if wizard provided one
49    if let Some(answers) = wizard_answers
50        && answers.create_first_task
51        && let (Some(title), Some(description)) = (
52            answers.first_task_title.clone(),
53            answers.first_task_description.clone(),
54        )
55    {
56        let now = time::OffsetDateTime::now_utc();
57        let timestamp = now
58            .format(&time::format_description::well_known::Rfc3339)
59            .unwrap_or_else(|_| now.to_string());
60
61        let task_id = format!("{}-{:0>width$}", id_prefix, 1, width = id_width);
62
63        let task = Task {
64            id: task_id,
65            status: TaskStatus::Todo,
66            title,
67            description: None,
68            priority: answers.first_task_priority,
69            tags: vec!["onboarding".to_string()],
70            scope: vec![],
71            evidence: vec![],
72            plan: vec![],
73            notes: vec![],
74            request: Some(description),
75            agent: None,
76            created_at: Some(timestamp.clone()),
77            updated_at: Some(timestamp),
78            completed_at: None,
79            started_at: None,
80            estimated_minutes: None,
81            actual_minutes: None,
82            scheduled_start: None,
83            depends_on: vec![],
84            blocks: vec![],
85            relates_to: vec![],
86            duplicates: None,
87            custom_fields: std::collections::HashMap::new(),
88            parent_id: None,
89        };
90
91        queue.tasks.push(task);
92    }
93
94    let rendered = serde_json::to_string_pretty(&queue).context("serialize queue JSON")?;
95    fsutil::write_atomic(path, rendered.as_bytes())
96        .with_context(|| format!("write queue JSON {}", path.display()))?;
97    Ok(FileInitStatus::Created)
98}
99
100/// Write done file (archive for completed tasks).
101pub fn write_done(
102    path: &Path,
103    force: bool,
104    id_prefix: &str,
105    id_width: usize,
106) -> Result<FileInitStatus> {
107    if path.exists() && !force {
108        // Validate existing file by trying to load it
109        let queue = queue::load_queue(path)?;
110        queue::validate_queue(&queue, id_prefix, id_width)
111            .with_context(|| format!("validate existing done {}", path.display()))?;
112        return Ok(FileInitStatus::Valid);
113    }
114    if let Some(parent) = path.parent() {
115        fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
116    }
117    let queue = QueueFile::default();
118    let rendered = serde_json::to_string_pretty(&queue).context("serialize done JSON")?;
119    fsutil::write_atomic(path, rendered.as_bytes())
120        .with_context(|| format!("write done JSON {}", path.display()))?;
121    Ok(FileInitStatus::Created)
122}
123
124/// Write config file, integrating wizard answers if provided.
125pub fn write_config(
126    path: &Path,
127    force: bool,
128    wizard_answers: Option<&WizardAnswers>,
129) -> Result<FileInitStatus> {
130    if path.exists() && !force {
131        // Validate existing config using load_layer to support JSONC with comments
132        crate::config::load_layer(path).with_context(|| {
133            format!(
134                "Config file exists but is invalid JSON/JSONC: {}. Use --force to overwrite.",
135                path.display()
136            )
137        })?;
138        return Ok(FileInitStatus::Valid);
139    }
140    if let Some(parent) = path.parent() {
141        fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
142    }
143
144    // Build config with wizard answers or defaults
145    let config_json = if let Some(answers) = wizard_answers {
146        let runner_str = format!("{:?}", answers.runner).to_lowercase();
147        let model_str = if answers.model.contains("/") || answers.model.len() > 20 {
148            // Custom model string
149            answers.model.clone()
150        } else {
151            answers.model.clone()
152        };
153
154        serde_json::json!({
155            "version": 1,
156            "agent": {
157                "runner": runner_str,
158                "model": model_str,
159                "phases": answers.phases
160            }
161        })
162    } else {
163        serde_json::json!({ "version": 1 })
164    };
165
166    let rendered = serde_json::to_string_pretty(&config_json).context("serialize config JSON")?;
167    fsutil::write_atomic(path, rendered.as_bytes())
168        .with_context(|| format!("write config JSON {}", path.display()))?;
169    Ok(FileInitStatus::Created)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::config;
176    use crate::contracts::{Config, Runner, TaskPriority};
177    use tempfile::TempDir;
178
179    fn resolved_for(dir: &TempDir) -> config::Resolved {
180        let repo_root = dir.path().to_path_buf();
181        let queue_path = repo_root.join(".ralph/queue.jsonc");
182        let done_path = repo_root.join(".ralph/done.jsonc");
183        let project_config_path = Some(repo_root.join(".ralph/config.jsonc"));
184        config::Resolved {
185            config: Config::default(),
186            repo_root,
187            queue_path,
188            done_path,
189            id_prefix: "RQ".to_string(),
190            id_width: 4,
191            global_config_path: None,
192            project_config_path,
193        }
194    }
195
196    #[test]
197    fn init_creates_missing_files() -> Result<()> {
198        let dir = TempDir::new()?;
199        let resolved = resolved_for(&dir);
200
201        let queue_status = write_queue(
202            &resolved.queue_path,
203            false,
204            &resolved.id_prefix,
205            resolved.id_width,
206            None,
207        )?;
208        let done_status = write_done(
209            &resolved.done_path,
210            false,
211            &resolved.id_prefix,
212            resolved.id_width,
213        )?;
214        let config_status =
215            write_config(resolved.project_config_path.as_ref().unwrap(), false, None)?;
216
217        assert_eq!(queue_status, FileInitStatus::Created);
218        assert_eq!(done_status, FileInitStatus::Created);
219        assert_eq!(config_status, FileInitStatus::Created);
220
221        let queue = crate::queue::load_queue(&resolved.queue_path)?;
222        assert_eq!(queue.version, 1);
223        let done = crate::queue::load_queue(&resolved.done_path)?;
224        assert_eq!(done.version, 1);
225        let raw_cfg = std::fs::read_to_string(resolved.project_config_path.as_ref().unwrap())?;
226        let cfg: Config = serde_json::from_str(&raw_cfg)?;
227        assert_eq!(cfg.version, 1);
228
229        Ok(())
230    }
231
232    #[test]
233    fn init_skips_existing_when_not_forced() -> Result<()> {
234        let dir = TempDir::new()?;
235        let resolved = resolved_for(&dir);
236        std::fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
237        let queue_json = r#"{
238  "version": 1,
239  "tasks": [
240    {
241      "id": "RQ-0001",
242      "status": "todo",
243      "title": "Keep",
244      "tags": ["code"],
245      "scope": ["x"],
246      "evidence": ["y"],
247      "plan": ["z"],
248      "request": "test",
249      "created_at": "2026-01-18T00:00:00Z",
250      "updated_at": "2026-01-18T00:00:00Z"
251    }
252  ]
253}"#;
254        std::fs::write(&resolved.queue_path, queue_json)?;
255        let done_json = r#"{
256  "version": 1,
257  "tasks": [
258    {
259      "id": "RQ-0002",
260      "status": "done",
261      "title": "Done",
262      "tags": ["code"],
263      "scope": ["x"],
264      "evidence": ["y"],
265      "plan": ["z"],
266      "request": "test",
267      "created_at": "2026-01-18T00:00:00Z",
268      "updated_at": "2026-01-18T00:00:00Z",
269      "completed_at": "2026-01-18T00:00:00Z"
270    }
271  ]
272}"#;
273        std::fs::write(&resolved.done_path, done_json)?;
274        let config_json = r#"{
275  "version": 1,
276  "queue": {
277    "file": ".ralph/queue.json"
278  }
279}"#;
280        std::fs::write(resolved.project_config_path.as_ref().unwrap(), config_json)?;
281
282        let queue_status = write_queue(
283            &resolved.queue_path,
284            false,
285            &resolved.id_prefix,
286            resolved.id_width,
287            None,
288        )?;
289        let done_status = write_done(
290            &resolved.done_path,
291            false,
292            &resolved.id_prefix,
293            resolved.id_width,
294        )?;
295        let config_status =
296            write_config(resolved.project_config_path.as_ref().unwrap(), false, None)?;
297
298        assert_eq!(queue_status, FileInitStatus::Valid);
299        assert_eq!(done_status, FileInitStatus::Valid);
300        assert_eq!(config_status, FileInitStatus::Valid);
301
302        let raw = std::fs::read_to_string(&resolved.queue_path)?;
303        assert!(raw.contains("Keep"));
304        let done_raw = std::fs::read_to_string(&resolved.done_path)?;
305        assert!(done_raw.contains("Done"));
306
307        Ok(())
308    }
309
310    #[test]
311    fn init_overwrites_when_forced() -> Result<()> {
312        let dir = TempDir::new()?;
313        let resolved = resolved_for(&dir);
314        std::fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
315        std::fs::write(&resolved.queue_path, r#"{"version":1,"tasks":[]}"#)?;
316        std::fs::write(&resolved.done_path, r#"{"version":1,"tasks":[]}"#)?;
317        std::fs::write(
318            resolved.project_config_path.as_ref().unwrap(),
319            r#"{"version":1,"project_type":"docs"}"#,
320        )?;
321
322        let queue_status = write_queue(
323            &resolved.queue_path,
324            true,
325            &resolved.id_prefix,
326            resolved.id_width,
327            None,
328        )?;
329        let done_status = write_done(
330            &resolved.done_path,
331            true,
332            &resolved.id_prefix,
333            resolved.id_width,
334        )?;
335        let config_status =
336            write_config(resolved.project_config_path.as_ref().unwrap(), true, None)?;
337
338        assert_eq!(queue_status, FileInitStatus::Created);
339        assert_eq!(done_status, FileInitStatus::Created);
340        assert_eq!(config_status, FileInitStatus::Created);
341
342        let cfg_raw = std::fs::read_to_string(resolved.project_config_path.as_ref().unwrap())?;
343        let cfg: Config = serde_json::from_str(&cfg_raw)?;
344        assert_eq!(cfg.project_type, Some(crate::contracts::ProjectType::Code));
345
346        Ok(())
347    }
348
349    #[test]
350    fn init_fails_on_invalid_existing_queue() -> Result<()> {
351        let dir = TempDir::new()?;
352        let resolved = resolved_for(&dir);
353        std::fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
354
355        // Create a queue with an invalid ID prefix (WRONG-0001 vs RQ)
356        let queue_json = r#"{
357  "version": 1,
358  "tasks": [
359    {
360      "id": "WRONG-0001",
361      "status": "todo",
362      "title": "Bad ID",
363      "tags": [],
364      "scope": [],
365      "evidence": [],
366      "plan": [],
367      "request": "test",
368      "created_at": "2026-01-18T00:00:00Z",
369      "updated_at": "2026-01-18T00:00:00Z"
370    }
371  ]
372}"#;
373        std::fs::write(&resolved.queue_path, queue_json)?;
374        std::fs::write(&resolved.done_path, r#"{"version":1,"tasks":[]}"#)?;
375        std::fs::write(
376            resolved.project_config_path.as_ref().unwrap(),
377            r#"{"version":1,"project_type":"code"}"#,
378        )?;
379
380        let result = write_queue(
381            &resolved.queue_path,
382            false,
383            &resolved.id_prefix,
384            resolved.id_width,
385            None,
386        );
387
388        assert!(result.is_err());
389        let err = result.unwrap_err();
390        assert!(err.to_string().contains("validate existing queue"));
391
392        Ok(())
393    }
394
395    #[test]
396    fn init_fails_on_invalid_existing_done() -> Result<()> {
397        let dir = TempDir::new()?;
398        let resolved = resolved_for(&dir);
399        std::fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
400
401        std::fs::write(&resolved.queue_path, r#"{"version":1,"tasks":[]}"#)?;
402
403        // Create a done file with a task that has invalid ID prefix
404        let done_json = r#"{
405  "version": 1,
406  "tasks": [
407    {
408      "id": "WRONG-0002",
409      "status": "done",
410      "title": "Bad ID",
411      "tags": [],
412      "scope": [],
413      "evidence": [],
414      "plan": [],
415      "request": "test",
416      "created_at": "2026-01-18T00:00:00Z",
417      "updated_at": "2026-01-18T00:00:00Z"
418    }
419  ]
420}"#;
421        std::fs::write(&resolved.done_path, done_json)?;
422        std::fs::write(
423            resolved.project_config_path.as_ref().unwrap(),
424            r#"{"version":1,"project_type":"code"}"#,
425        )?;
426
427        let result = write_done(
428            &resolved.done_path,
429            false,
430            &resolved.id_prefix,
431            resolved.id_width,
432        );
433
434        assert!(result.is_err());
435        let err = result.unwrap_err();
436        assert!(err.to_string().contains("validate existing done"));
437
438        Ok(())
439    }
440
441    #[test]
442    fn init_with_wizard_answers_creates_configured_files() -> Result<()> {
443        let dir = TempDir::new()?;
444        let resolved = resolved_for(&dir);
445
446        let wizard_answers = WizardAnswers {
447            runner: Runner::Codex,
448            model: "gpt-5.4".to_string(),
449            phases: 2,
450            create_first_task: true,
451            first_task_title: Some("Test task".to_string()),
452            first_task_description: Some("Test description".to_string()),
453            first_task_priority: TaskPriority::High,
454        };
455
456        // Manually write the queue with wizard answers to test the write_queue function
457        write_queue(
458            &resolved.queue_path,
459            true,
460            &resolved.id_prefix,
461            resolved.id_width,
462            Some(&wizard_answers),
463        )?;
464
465        write_config(
466            resolved.project_config_path.as_ref().unwrap(),
467            true,
468            Some(&wizard_answers),
469        )?;
470
471        // Verify config has correct runner and phases
472        let cfg_raw = std::fs::read_to_string(resolved.project_config_path.as_ref().unwrap())?;
473        let cfg: Config = serde_json::from_str(&cfg_raw)?;
474        assert_eq!(cfg.agent.runner, Some(Runner::Codex));
475        assert_eq!(cfg.agent.phases, Some(2));
476
477        // Verify queue has first task
478        let queue = crate::queue::load_queue(&resolved.queue_path)?;
479        assert_eq!(queue.tasks.len(), 1);
480        assert_eq!(queue.tasks[0].title, "Test task");
481        assert_eq!(queue.tasks[0].priority, TaskPriority::High);
482
483        Ok(())
484    }
485}