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": 2,
156            "agent": {
157                "runner": runner_str,
158                "model": model_str,
159                "phases": answers.phases
160            }
161        })
162    } else {
163        serde_json::json!({ "version": 2 })
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;