1use 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
27pub 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 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 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
100pub 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 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
124pub fn write_config(
126 path: &Path,
127 force: bool,
128 wizard_answers: Option<&WizardAnswers>,
129) -> Result<FileInitStatus> {
130 if path.exists() && !force {
131 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 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 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 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 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 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 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 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}