Skip to main content

newt_core/
roadmap_file.rs

1//! #1082 roadmap-as-code: the on-repo TOML codec for a roadmap.
2//!
3//! The `roadmaps` table in the conversation store is a per-machine *working
4//! copy*; the file this module reads and writes — checked into the repo at
5//! [`DEFAULT_ROADMAP_FILE`] — is the *authority*. `/roadmap export` serializes
6//! the active roadmap here; `/roadmap import` on a fresh checkout bootstraps
7//! the working copy back (upsert by roadmap id, so re-import updates in
8//! place). This module is the pure codec only: no filesystem, no store —
9//! those edges live at the command layer in `newt-tui`.
10
11use serde::{Deserialize, Serialize};
12
13use crate::plan::Plan;
14
15/// The on-file schema version. Independent of the store's
16/// `ROADMAP_SCHEMA_VERSION` column (same tree shape today, but the file
17/// format and the DB column can evolve separately); bumped only on a
18/// forward-incompatible change to this file's shape.
19pub const ROADMAP_FILE_SCHEMA_VERSION: i64 = 1;
20
21/// Where the roadmap file lives inside a workspace, alongside the repo's
22/// other checked-in newt state (`.newt/bundled-skills/`, …).
23pub const DEFAULT_ROADMAP_FILE: &str = ".newt/roadmap.toml";
24
25/// A roadmap as it appears on disk: id + title + the Roadmap→Phase→Plan→Task
26/// tree. `deny_unknown_fields` keeps the schema loud, matching `plan.rs`.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct RoadmapFile {
30    /// [`ROADMAP_FILE_SCHEMA_VERSION`] at write time; imports refuse a
31    /// version newer than they understand rather than mis-reading it.
32    pub schema_version: i64,
33    /// The roadmap's store id, preserved across export/import so the repo
34    /// file and every machine's working copy name the same roadmap.
35    pub id: String,
36    /// Human title, as shown by `/roadmap list`.
37    pub title: String,
38    /// The node tree. Serialized under `[tree]` / `[[tree.subtask]]`.
39    pub tree: Plan,
40}
41
42impl RoadmapFile {
43    /// Wrap a store roadmap for export, stamping the current schema version.
44    #[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    /// Serialize to the on-repo TOML text.
55    ///
56    /// # Errors
57    /// Returns the `toml` serialization error (should not occur for a
58    /// well-formed tree).
59    pub fn to_toml_string(&self) -> anyhow::Result<String> {
60        Ok(toml::to_string_pretty(self)?)
61    }
62
63    /// Parse and validate on-repo TOML text. Fail-loud by design: a malformed
64    /// or future-versioned file is a hard error so an import never writes a
65    /// garbage tree into the store.
66    ///
67    /// # Errors
68    /// On TOML that does not match the schema, a `schema_version` newer than
69    /// this build understands, an id outside the record-id alphabet
70    /// (ASCII alphanumeric + `-`), or a blank title.
71    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        // export → import → export is byte-identical (#1082 regression).
126        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}