tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
#[allow(clippy::wildcard_imports)]
use super::*;

impl Config {
    /// Load a `tix.toml` at `path`, emitting a warning through `sink` for
    /// every cleartext token read from the file.
    ///
    /// Parse and validation failures surface as [`TixError::Config`] with
    /// the file's path and (where the TOML parser reports it) line.
    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);
        // File stores win over the legacy tables whenever their directory
        // exists. Absent directories mean an existing project: its
        // `[kanban.*]`/`[cycles.*]` tables load exactly as before.
        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() {
                // The board **files** own a board's shape — its columns, its
                // categories, its display — and `tix.toml` owns its **limits**:
                // C-001 puts the work-in-progress configuration there, and a
                // scaffolded repository (`tix init`) keeps its board in
                // `docs/boards/<name>.md`. Replacing the section wholesale, which is
                // what this did before limits existed, silently dropped them — so a
                // `tix wip set` on such a project wrote a limit that nothing ever
                // read (found by WP06's MCP test, whose `tix init` repository showed
                // no limit at all).
                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))?;
        // The records' half of the capacity section's validation: a cycle reference
        // resolves against `docs/cycles/` (or the legacy `[cycles.<name>]` tables when there
        // is no store), and the records are what tell a generated cycle's name from a
        // hand-written one. `capacity::config::set_capacity` is the other gate, over the
        // same records, for every programmatic change (WP01).
        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)
    }

    /// Load the config for a project root: `tix.toml` at the root if
    /// present, otherwise the defaults. An absent file is *not* an error —
    /// the CLI layer decides whether to nag about `tix init`.
    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}"))
    }

    /// Find the project root for `cwd` by walking upward for a `tix.toml`.
    /// Returns `None` when no ancestor (including `cwd` itself) holds one.
    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
    }

    /// A copy of this config with every token stripped, for writing.
    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
    }

    /// Write `tix.toml` atomically (temp file + rename in the same
    /// directory, with an `fsync` before the rename — NFR-006).
    ///
    /// Tokens are never written to disk, so a round-trip `load` → `save`
    /// drops any cleartext token (expected: prefer env vars).
    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)?;

        // A file store that exists is authoritative for its records: write
        // them there and leave them out of `tix.toml`. Every other setting
        // still round-trips through the TOML file.
        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(())
    }
}

/// Prefix a configuration error with the file it came from.
///
/// A load failure has to name its source: a program that coordinates repositories
/// (tix #1529) reads more than one `tix.toml`, and a message that does not say which one
/// was refused is a message nobody can act on. Shared by the two validation gates a load
/// runs, so both name the file the same way.
fn at_file(path: &Path, error: TixError) -> TixError {
    match error {
        TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
        other => other,
    }
}

/// Redraw a fresh id until it is not already a key of `registry`.
///
/// Shared by [`Config::allocate_project_id`] and [`Config::allocate_epic_id`]. It is
/// the exact half of the never-reused guarantee: a *stored* id is never handed out a
/// second time. A deletion cannot reset it, because the allocator holds no state a
/// removal could rewind — the remaining half is carried by the id shape itself
/// ([`StableId::generate`]).
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;
        }
    }
}

/// Reorder a named-config `IndexMap` to the declaration order of its
/// `[<section>.<name>]` section headers in the source document (D-02:
/// declaration order = tab order). The `toml` crate sorts table keys on
/// deserialize, so the parser's order is not trustworthy; this returns to the
/// document's byte order. Applies to `[kanban.*]`, `[cycles.*]`, `[teams.*]`,
/// `[projects.*]`, `[epics.*]`, `[initiatives.*]`, `[cycle_schedules.*]` and
/// `[views.*]` (the last through
/// its own wrapper). Names are unquoted when the header used a quoted key (e.g.
/// `[cycles."Cycle 1"]`).
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;
}

/// Strip a single pair of surrounding single or double quotes from a TOML
/// table-header key, when present.
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)
    }
}