Skip to main content

tix/config/
io.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl Config {
5    /// Load a `tix.toml` at `path`, emitting a warning through `sink` for
6    /// every cleartext token read from the file.
7    ///
8    /// Parse and validation failures surface as [`TixError::Config`] with
9    /// the file's path and (where the TOML parser reports it) line.
10    pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
11        let contents = std::fs::read_to_string(path)
12            .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
13        if contents.contains("keybindings") {
14            let raw: toml::Value = toml::from_str(&contents)
15                .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
16            if raw.get("keybindings").is_some() {
17                return Err(TixError::Config(format!(
18                    "{}: `[keybindings]` belongs in `{}`; move each table there without the \
19                     outer wrapper (for example `[keybindings.board]` becomes `[board]`)",
20                    path.display(),
21                    crate::keybindings::KEYBINDINGS_FILE
22                )));
23            }
24        }
25        let mut config: Config = toml::from_str(&contents)
26            .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
27        config.restore_declaration_order(&contents);
28        // File stores win over the legacy tables whenever their directory
29        // exists. Absent directories mean an existing project: its
30        // `[kanban.*]`/`[cycles.*]` tables load exactly as before.
31        let root = path
32            .parent()
33            .filter(|p| !p.as_os_str().is_empty())
34            .unwrap_or_else(|| Path::new("."));
35        let board_store = BoardStore::new(root);
36        if board_store.exists() {
37            let boards = board_store
38                .list()
39                .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
40            if !boards.is_empty() {
41                // The board **files** own a board's shape — its columns, its
42                // categories, its display — and `tix.toml` owns its **limits**:
43                // C-001 puts the work-in-progress configuration there, and a
44                // scaffolded repository (`tix init`) keeps its board in
45                // `docs/boards/<name>.md`. Replacing the section wholesale, which is
46                // what this did before limits existed, silently dropped them — so a
47                // `tix wip set` on such a project wrote a limit that nothing ever
48                // read (found by WP06's MCP test, whose `tix init` repository showed
49                // no limit at all).
50                let declared = std::mem::take(&mut config.kanban.configurations);
51                config.kanban = KanbanSection {
52                    configurations: boards
53                        .into_iter()
54                        .map(|(name, mut board)| {
55                            if let Some(from_toml) = declared.get(&name) {
56                                board.wip = from_toml.wip.clone();
57                            }
58                            (name, board)
59                        })
60                        .collect(),
61                };
62            }
63        }
64        let cycle_store = CycleStore::new(root);
65        if cycle_store.exists() {
66            let cycles = cycle_store
67                .list()
68                .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
69            config.cycles = cycles
70                .into_iter()
71                .map(|record| (record.name.clone(), record.into()))
72                .collect();
73        }
74        if config.kanban.configurations.is_empty() {
75            config.kanban = KanbanSection {
76                configurations: IndexMap::from([(
77                    "default".to_string(),
78                    BoardConfiguration::default(),
79                )]),
80            };
81        }
82        config.validate().map_err(|e| at_file(path, e))?;
83        // The records' half of the capacity section's validation: a cycle reference
84        // resolves against `docs/cycles/` (or the legacy `[cycles.<name>]` tables when there
85        // is no store), and the records are what tell a generated cycle's name from a
86        // hand-written one. `capacity::config::set_capacity` is the other gate, over the
87        // same records, for every programmatic change (WP01).
88        crate::capacity::config::validate_cycle_references(&config.capacity, &config, root)
89            .map_err(|e| at_file(path, e))?;
90        for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
91            .into_iter()
92            .flatten()
93        {
94            if remote.token.is_some() {
95                sink(
96                    "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
97                );
98            }
99        }
100        Ok(config)
101    }
102
103    /// Load the config for a project root: `tix.toml` at the root if
104    /// present, otherwise the defaults. An absent file is *not* an error —
105    /// the CLI layer decides whether to nag about `tix init`.
106    pub fn load(project_root: &Path) -> Result<Config> {
107        let path = project_root.join("tix.toml");
108        if !path.is_file() {
109            return Ok(Config::default());
110        }
111        Self::load_with_warning(&path, |w| eprintln!("{w}"))
112    }
113
114    /// Find the project root for `cwd` by walking upward for a `tix.toml`.
115    /// Returns `None` when no ancestor (including `cwd` itself) holds one.
116    pub fn project_root(cwd: &Path) -> Option<PathBuf> {
117        let mut dir = Some(cwd.to_path_buf());
118        while let Some(current) = dir {
119            if current.join("tix.toml").is_file() {
120                return Some(current);
121            }
122            dir = current.parent().map(Path::to_path_buf);
123        }
124        None
125    }
126
127    /// A copy of this config with every token stripped, for writing.
128    fn sanitized_for_disk(&self) -> Config {
129        let mut copy = self.clone();
130        if let Some(gitlab) = copy.export.gitlab.as_mut() {
131            gitlab.token = None;
132        }
133        if let Some(linear) = copy.export.linear.as_mut() {
134            linear.token = None;
135        }
136        copy
137    }
138
139    /// Write `tix.toml` atomically (temp file + rename in the same
140    /// directory, with an `fsync` before the rename — NFR-006).
141    ///
142    /// Tokens are never written to disk, so a round-trip `load` → `save`
143    /// drops any cleartext token (expected: prefer env vars).
144    pub fn save(&self, path: &Path) -> Result<()> {
145        let dir = match path.parent() {
146            Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
147            _ => PathBuf::from("."),
148        };
149        std::fs::create_dir_all(&dir)?;
150
151        // A file store that exists is authoritative for its records: write
152        // them there and leave them out of `tix.toml`. Every other setting
153        // still round-trips through the TOML file.
154        let board_store = BoardStore::new(&dir);
155        let board_store_active = board_store.exists();
156        if board_store_active {
157            for (name, board) in &self.kanban.configurations {
158                let filename = format!("{}.md", crate::slug::slugify(name));
159                let body = RecordStore::new(board_store.dir()).body_of(&filename);
160                board_store.save(name, board, body.as_deref())?;
161            }
162        }
163        let cycle_store = CycleStore::new(&dir);
164        let cycle_store_active = cycle_store.exists();
165        if cycle_store_active {
166            for (name, cycle) in &self.cycles {
167                if is_implicit_default(name, cycle) {
168                    continue;
169                }
170                let filename = format!("{}.md", crate::slug::slugify(name));
171                let body = RecordStore::new(cycle_store.dir()).body_of(&filename);
172                cycle_store.save(name, cycle, body.as_deref())?;
173            }
174        }
175
176        let mut disk = self.sanitized_for_disk();
177        if board_store_active {
178            disk.kanban = KanbanSection::default();
179        }
180        if cycle_store_active {
181            disk.cycles.clear();
182        }
183        let contents = toml::to_string_pretty(&disk)
184            .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
185
186        let file_name = path
187            .file_name()
188            .and_then(|n| n.to_str())
189            .unwrap_or("tix.toml");
190        let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
191
192        let write_result = (|| -> std::io::Result<()> {
193            let mut f = std::fs::File::create(&tmp)?;
194            use std::io::Write;
195            f.write_all(contents.as_bytes())?;
196            f.sync_all()?;
197            Ok(())
198        })();
199        if let Err(e) = write_result {
200            let _ = std::fs::remove_file(&tmp);
201            return Err(TixError::Io(e));
202        }
203
204        if let Err(e) = std::fs::rename(&tmp, path) {
205            let _ = std::fs::remove_file(&tmp);
206            return Err(TixError::Io(e));
207        }
208        Ok(())
209    }
210}
211
212/// Prefix a configuration error with the file it came from.
213///
214/// A load failure has to name its source: a program that coordinates repositories
215/// (tix #1529) reads more than one `tix.toml`, and a message that does not say which one
216/// was refused is a message nobody can act on. Shared by the two validation gates a load
217/// runs, so both name the file the same way.
218fn at_file(path: &Path, error: TixError) -> TixError {
219    match error {
220        TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
221        other => other,
222    }
223}
224
225/// Redraw a fresh id until it is not already a key of `registry`.
226///
227/// Shared by [`Config::allocate_project_id`] and [`Config::allocate_epic_id`]. It is
228/// the exact half of the never-reused guarantee: a *stored* id is never handed out a
229/// second time. A deletion cannot reset it, because the allocator holds no state a
230/// removal could rewind — the remaining half is carried by the id shape itself
231/// ([`StableId::generate`]).
232pub(crate) fn allocate_stable_id<I: StableId, V>(
233    registry: &IndexMap<String, V>,
234    mut draw: impl FnMut() -> I,
235) -> I {
236    loop {
237        let candidate = draw();
238        if !registry.contains_key(candidate.as_str()) {
239            return candidate;
240        }
241    }
242}
243
244/// Reorder a named-config `IndexMap` to the declaration order of its
245/// `[<section>.<name>]` section headers in the source document (D-02:
246/// declaration order = tab order). The `toml` crate sorts table keys on
247/// deserialize, so the parser's order is not trustworthy; this returns to the
248/// document's byte order. Applies to `[kanban.*]`, `[cycles.*]`, `[teams.*]`,
249/// `[projects.*]`, `[epics.*]`, `[initiatives.*]`, `[cycle_schedules.*]` and
250/// `[views.*]` (the last through
251/// its own wrapper). Names are unquoted when the header used a quoted key (e.g.
252/// `[cycles."Cycle 1"]`).
253pub(crate) fn restore_section_order<V: Clone>(
254    map: &mut IndexMap<String, V>,
255    contents: &str,
256    section: &str,
257) {
258    let mut order: Vec<String> = Vec::new();
259    for line in contents.lines() {
260        let line = line.trim();
261        if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
262            && let Some((head, key)) = rest.split_once('.')
263            && head.trim() == section
264            && !key.trim().contains('.')
265            && let Some(name) = unquote_key(key.trim())
266            && !order.iter().any(|n| n == name)
267        {
268            order.push(name.to_string());
269        }
270    }
271    if order.is_empty() {
272        return;
273    }
274    let mut ordered = IndexMap::new();
275    for name in &order {
276        if let Some(value) = map.shift_remove(name) {
277            ordered.insert(name.clone(), value);
278        }
279    }
280    ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
281    *map = ordered;
282}
283
284/// Strip a single pair of surrounding single or double quotes from a TOML
285/// table-header key, when present.
286fn unquote_key(key: &str) -> Option<&str> {
287    if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
288        Some(inner)
289    } else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
290        Some(inner)
291    } else {
292        Some(key)
293    }
294}