#[allow(clippy::wildcard_imports)]
use super::*;
impl Config {
pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
let contents = std::fs::read_to_string(path)
.map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
let mut config: Config = toml::from_str(&contents)
.map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
config.restore_declaration_order(&contents);
let root = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let board_store = BoardStore::new(root);
if board_store.exists() {
let boards = board_store
.list()
.map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
if !boards.is_empty() {
let declared = std::mem::take(&mut config.kanban.configurations);
config.kanban = KanbanSection {
configurations: boards
.into_iter()
.map(|(name, mut board)| {
if let Some(from_toml) = declared.get(&name) {
board.wip = from_toml.wip.clone();
}
(name, board)
})
.collect(),
};
}
}
let cycle_store = CycleStore::new(root);
if cycle_store.exists() {
let cycles = cycle_store
.list()
.map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
config.cycles = cycles
.into_iter()
.map(|record| (record.name.clone(), record.into()))
.collect();
}
if config.kanban.configurations.is_empty() {
config.kanban = KanbanSection {
configurations: IndexMap::from([(
"default".to_string(),
BoardConfiguration::default(),
)]),
};
}
config.validate().map_err(|e| at_file(path, e))?;
crate::capacity::config::validate_cycle_references(&config.capacity, &config, root)
.map_err(|e| at_file(path, e))?;
for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
.into_iter()
.flatten()
{
if remote.token.is_some() {
sink(
"Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
);
}
}
Ok(config)
}
pub fn load(project_root: &Path) -> Result<Config> {
let path = project_root.join("tix.toml");
if !path.is_file() {
return Ok(Config::default());
}
Self::load_with_warning(&path, |w| eprintln!("{w}"))
}
pub fn project_root(cwd: &Path) -> Option<PathBuf> {
let mut dir = Some(cwd.to_path_buf());
while let Some(current) = dir {
if current.join("tix.toml").is_file() {
return Some(current);
}
dir = current.parent().map(Path::to_path_buf);
}
None
}
fn sanitized_for_disk(&self) -> Config {
let mut copy = self.clone();
if let Some(gitlab) = copy.export.gitlab.as_mut() {
gitlab.token = None;
}
if let Some(linear) = copy.export.linear.as_mut() {
linear.token = None;
}
copy
}
pub fn save(&self, path: &Path) -> Result<()> {
let dir = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
std::fs::create_dir_all(&dir)?;
let board_store = BoardStore::new(&dir);
let board_store_active = board_store.exists();
if board_store_active {
for (name, board) in &self.kanban.configurations {
let filename = format!("{}.md", crate::slug::slugify(name));
let body = RecordStore::new(board_store.dir()).body_of(&filename);
board_store.save(name, board, body.as_deref())?;
}
}
let cycle_store = CycleStore::new(&dir);
let cycle_store_active = cycle_store.exists();
if cycle_store_active {
for (name, cycle) in &self.cycles {
if is_implicit_default(name, cycle) {
continue;
}
let filename = format!("{}.md", crate::slug::slugify(name));
let body = RecordStore::new(cycle_store.dir()).body_of(&filename);
cycle_store.save(name, cycle, body.as_deref())?;
}
}
let mut disk = self.sanitized_for_disk();
if board_store_active {
disk.kanban = KanbanSection::default();
}
if cycle_store_active {
disk.cycles.clear();
}
let contents = toml::to_string_pretty(&disk)
.map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("tix.toml");
let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
let write_result = (|| -> std::io::Result<()> {
let mut f = std::fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(contents.as_bytes())?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = write_result {
let _ = std::fs::remove_file(&tmp);
return Err(TixError::Io(e));
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(TixError::Io(e));
}
Ok(())
}
}
fn at_file(path: &Path, error: TixError) -> TixError {
match error {
TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
other => other,
}
}
pub(crate) fn allocate_stable_id<I: StableId, V>(
registry: &IndexMap<String, V>,
mut draw: impl FnMut() -> I,
) -> I {
loop {
let candidate = draw();
if !registry.contains_key(candidate.as_str()) {
return candidate;
}
}
}
pub(crate) fn restore_section_order<V: Clone>(
map: &mut IndexMap<String, V>,
contents: &str,
section: &str,
) {
let mut order: Vec<String> = Vec::new();
for line in contents.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
&& let Some((head, key)) = rest.split_once('.')
&& head.trim() == section
&& !key.trim().contains('.')
&& let Some(name) = unquote_key(key.trim())
&& !order.iter().any(|n| n == name)
{
order.push(name.to_string());
}
}
if order.is_empty() {
return;
}
let mut ordered = IndexMap::new();
for name in &order {
if let Some(value) = map.shift_remove(name) {
ordered.insert(name.clone(), value);
}
}
ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
*map = ordered;
}
fn unquote_key(key: &str) -> Option<&str> {
if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
Some(inner)
} else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
Some(inner)
} else {
Some(key)
}
}