newt_core/
roadmap_file.rs1use serde::{Deserialize, Serialize};
12
13use crate::plan::Plan;
14
15pub const ROADMAP_FILE_SCHEMA_VERSION: i64 = 1;
20
21pub const DEFAULT_ROADMAP_FILE: &str = ".newt/roadmap.toml";
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct RoadmapFile {
30 pub schema_version: i64,
33 pub id: String,
36 pub title: String,
38 pub tree: Plan,
40}
41
42impl RoadmapFile {
43 #[must_use]
45 pub fn new(id: impl Into<String>, title: impl Into<String>, tree: Plan) -> Self {
46 Self {
47 schema_version: ROADMAP_FILE_SCHEMA_VERSION,
48 id: id.into(),
49 title: title.into(),
50 tree,
51 }
52 }
53
54 pub fn to_toml_string(&self) -> anyhow::Result<String> {
60 Ok(toml::to_string_pretty(self)?)
61 }
62
63 pub fn from_toml_str(s: &str) -> anyhow::Result<Self> {
72 let file: Self = toml::from_str(s)?;
73 if file.schema_version > ROADMAP_FILE_SCHEMA_VERSION {
74 anyhow::bail!(
75 "roadmap file schema_version {} is newer than this newt understands ({}) — \
76 upgrade newt to import it",
77 file.schema_version,
78 ROADMAP_FILE_SCHEMA_VERSION
79 );
80 }
81 if file.id.is_empty()
82 || !file
83 .id
84 .chars()
85 .all(|c| c.is_ascii_alphanumeric() || c == '-')
86 {
87 anyhow::bail!("roadmap file has invalid id `{}`", file.id);
88 }
89 if file.title.trim().is_empty() {
90 anyhow::bail!("roadmap file has an empty title");
91 }
92 Ok(file)
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use crate::plan::{NodeKind, Subtask};
100
101 fn demo_tree() -> Plan {
102 let mut plan = Plan::default();
103 plan.subtasks.push(Subtask::node(
104 "node-1",
105 "v0.1.0 Keys & doctrine",
106 NodeKind::Phase,
107 None,
108 ));
109 plan.subtasks.push(Subtask::node(
110 "node-2",
111 "pane API as cockpit MCP tools",
112 NodeKind::Plan,
113 Some("node-1".into()),
114 ));
115 plan
116 }
117
118 #[test]
119 fn roadmap_file_round_trips_byte_identical() {
120 let file = RoadmapFile::new("rm-1", "Gilamonster 0.x", demo_tree());
121 let first = file.to_toml_string().unwrap();
122 let reparsed = RoadmapFile::from_toml_str(&first).unwrap();
123 assert_eq!(reparsed, file);
124 let second = reparsed.to_toml_string().unwrap();
125 assert_eq!(first, second);
127 }
128
129 #[test]
130 fn import_refuses_future_schema_version() {
131 let mut text = RoadmapFile::new("rm-1", "T", Plan::default())
132 .to_toml_string()
133 .unwrap();
134 text = text.replace("schema_version = 1", "schema_version = 99");
135 let err = RoadmapFile::from_toml_str(&text).unwrap_err().to_string();
136 assert!(err.contains("newer than this newt"), "{err}");
137 }
138
139 #[test]
140 fn import_refuses_bad_id_and_blank_title() {
141 let good = RoadmapFile::new("rm-1", "T", Plan::default())
142 .to_toml_string()
143 .unwrap();
144 let bad_id = good.replace("id = \"rm-1\"", "id = \"rm 1!\"");
145 assert!(RoadmapFile::from_toml_str(&bad_id)
146 .unwrap_err()
147 .to_string()
148 .contains("invalid id"));
149 let blank_title = good.replace("title = \"T\"", "title = \" \"");
150 assert!(RoadmapFile::from_toml_str(&blank_title)
151 .unwrap_err()
152 .to_string()
153 .contains("empty title"));
154 }
155
156 #[test]
157 fn import_refuses_unknown_fields_loudly() {
158 let mut text = RoadmapFile::new("rm-1", "T", Plan::default())
159 .to_toml_string()
160 .unwrap();
161 text.push_str("\nsurprise = true\n");
162 assert!(RoadmapFile::from_toml_str(&text).is_err());
163 }
164
165 #[test]
166 fn import_refuses_garbage_toml() {
167 assert!(RoadmapFile::from_toml_str("not = [toml").is_err());
168 assert!(RoadmapFile::from_toml_str("").is_err());
169 }
170}