1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl Config {
5 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 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 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 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 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 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 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 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 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
212fn 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
225pub(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
244pub(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
284fn 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}