tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The explicit triage-entry policy for remote imports (WP07 T034).
//!
//! Contract `cli-surface.md`: *"Imported triage routing is an explicit
//! documented flag/policy, never a silent remote-state bypass."* Research R8
//! and C-007 say the same thing from the other side: an import must not let a
//! remote state quietly win over the configured entry column, and it must not
//! change what the remote adapters themselves mean.
//!
//! So this module owns exactly one decision — **which local column a newly
//! imported issue lands in** — and deliberately owns nothing else:
//!
//! - The remote vocabulary (`opened`, `Done`, a Linear column label) stays in
//!   [`crate::sync::remote_to_local_status`]. It is handed to [`Router::place`]
//!   as `requested` rather than re-derived here, so neither side can drift into
//!   the other's job and no [`crate::sync::Tool`] appears in a signature here.
//! - Conflict resolution, remote ids, and adapter behaviour are untouched
//!   (T036). The policy governs **creation**, which is where a bypass actually
//!   happens: an import creating a file straight into `todo` is what C-007
//!   forbids. An *overwrite* of an issue that already exists locally is not an
//!   entry — it is governed by conflict resolution and the #183 transition
//!   policy, both unchanged, and this module is never consulted for it.
//! - Nothing here writes a file. [`crate::triage::record_entry`] stamps the
//!   metadata and `store.create` performs the write (T035), so the entry is made
//!   through the same seam every other local entry uses.
//!
//! The three inputs T034 names map onto the surface one-for-one: a
//! [`ImportPolicy::Default`] nothing-was-asked-for value, an explicit override
//! ([`ImportPolicy::Entry`] / [`ImportPolicy::Remote`]), and a refusal for a
//! policy the project cannot represent ([`resolve`]).

use crate::config::Config;
use crate::error::{Result, TixError};
use crate::triage::inbox;

/// Where a newly imported issue lands (T034's policy input).
///
/// Deliberately three values rather than two: "no flag" and "entry" behave
/// differently on a project with no `[triage]` section, and collapsing them
/// would make the default either refuse legacy imports or silently claim a
/// triage entry it did not perform. [`ImportPolicy::Default`] falls back and
/// reports nothing; [`ImportPolicy::Entry`] refuses and says so.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[allow(clippy::module_name_repetitions)]
pub enum ImportPolicy {
    /// Nothing was asked for: the documented default.
    ///
    /// Route into the configured entry column when the project has a
    /// `[triage]` section; otherwise leave the remote mapping alone. A legacy
    /// project with no `[triage]` imports exactly as it did before this seam
    /// existed — there is no entry column to route to, and inventing one would
    /// be a silent change of its own.
    #[default]
    Default,
    /// `--triage-policy entry`: route into the entry column, and **refuse** when
    /// the project has no `[triage]` section instead of quietly falling back.
    Entry,
    /// `--triage-policy remote`: keep the column the remote mapping named — an
    /// explicit, reported opt-out of entry routing.
    Remote,
}

impl ImportPolicy {
    /// The flag's spelling, for `--help` and for the refusal below.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Default => "default",
            Self::Entry => "entry",
            Self::Remote => "remote",
        }
    }

    /// Parse the flag's value.
    ///
    /// Only `entry` and `remote`: `default` is what an *absent* flag means, and
    /// offering it as a value would give a second spelling for omitting the
    /// flag. An unknown name is refused, never coerced — the same rule
    /// [`crate::triage::decision::DecisionKind`]'s parser follows, so two
    /// triage flags cannot disagree about what a bad value is.
    ///
    /// # Errors
    ///
    /// [`TixError::InvalidInput`] naming the value and the two that are legal.
    pub fn parse(raw: &str) -> Result<Self> {
        let normalised = raw.trim().to_ascii_lowercase();
        match normalised.as_str() {
            "entry" => Ok(Self::Entry),
            "remote" => Ok(Self::Remote),
            _ => Err(TixError::InvalidInput(format!(
                "unknown --triage-policy '{raw}' — use entry or remote"
            ))),
        }
    }
}

/// The policy, resolved once against one project's configuration.
///
/// Resolving before the loop is load-bearing rather than a micro-optimisation:
/// a policy this project cannot represent must refuse the **whole** import
/// before a single file is written, not fail three items in. [`Router`] is
/// therefore infallible to use — [`Router::place`] cannot fail, because
/// everything that could fail was already decided here.
#[derive(Debug, Clone)]
pub struct Router {
    /// The policy that was resolved.
    policy: ImportPolicy,
    /// The entry column to route into, or `None` when routing does not apply.
    entry: Option<String>,
}

/// One import's placement, after the policy has decided it (T034's output).
///
/// Carried rather than printed so the caller can put the same three numbers in
/// the report it writes (T035) without re-deriving them — the report and the
/// file cannot disagree because they read the same value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mapping {
    /// The remote state the adapter reported (`opened`, `Done`, ...).
    pub original: String,
    /// The column the generic remote mapping named for that state.
    pub requested: String,
    /// The column the issue will land in.
    pub landed: String,
    /// Which policy decided it.
    pub policy: ImportPolicy,
    /// Whether triage entry routing placed it.
    pub routed: bool,
}

impl Mapping {
    /// The report clause naming original, requested and landed (T035).
    ///
    /// Empty when the default policy did nothing beyond what the remote mapping
    /// already said — a legacy project's import report stays byte-identical to
    /// what it was before this seam existed, because there the policy decided
    /// nothing and a parenthetical announcing no decision would be noise. Every
    /// *explicit* policy, and every applied entry routing, is named.
    #[must_use]
    pub fn report_clause(&self) -> String {
        if !self.routed && self.policy == ImportPolicy::Default {
            return String::new();
        }
        let label = if self.routed {
            "triage entry".to_string()
        } else {
            format!("import policy {}", self.policy.as_str())
        };
        format!(
            " ({label}: remote `{}` requested `{}`, landed `{}`)",
            self.original, self.requested, self.landed
        )
    }
}

/// Resolve `policy` against this project's configuration (T034).
///
/// # Errors
///
/// - [`ImportPolicy::Entry`] on a project with no `[triage]` section: the
///   requested policy is **unrepresentable** here, and saying so is the point —
///   a silent fallback would be the exact bypass C-007 forbids, dressed up as
///   success.
/// - Anything [`inbox::scope`] refuses, most usefully a `[triage].entry_status`
///   the board does not have. The validator has one definition of what a valid
///   section is; re-checking the same rules here would be a second one.
///
/// [`ImportPolicy::Remote`] never consults the triage configuration at all: an
/// explicit opt-out that failed because of the section it opted out of would be
/// a flag that cannot be trusted.
pub fn resolve(config: &Config, policy: ImportPolicy) -> Result<Router> {
    if policy == ImportPolicy::Remote {
        return Ok(Router {
            policy,
            entry: None,
        });
    }
    let entry = inbox::scope(config)?.map(|scope| scope.entry_status);
    if policy == ImportPolicy::Entry && entry.is_none() {
        return Err(TixError::InvalidInput(
            "`--triage-policy entry` needs a `[triage]` section in tix.toml, and this project \
             has none"
                .to_string(),
        ));
    }
    Ok(Router { policy, entry })
}

impl Router {
    /// Place one **newly created** import (T034's output).
    ///
    /// `requested` arrives from `sync::remote_to_local_status` rather than being
    /// recomputed: the remote vocabulary belongs to `sync`, and this function
    /// decides only whether the configured entry column overrides it.
    #[must_use]
    pub fn place(&self, original: &str, requested: &str) -> Mapping {
        match self.entry.as_deref() {
            Some(column) => Mapping {
                original: original.to_string(),
                requested: requested.to_string(),
                landed: column.to_string(),
                policy: self.policy,
                routed: true,
            },
            None => Mapping {
                original: original.to_string(),
                requested: requested.to_string(),
                landed: requested.to_string(),
                policy: self.policy,
                routed: false,
            },
        }
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::config::{BoardConfiguration, Config, KanbanSection};
    use std::collections::BTreeMap;
    use std::path::PathBuf;

    // Every WP07 unit test is named `triage_import_*`, because the work
    // package's gate is `cargo test --test triage_import` and these are the
    // half of the gate that does not need a process.

    fn board(columns: &[&str]) -> Config {
        Config {
            kanban: KanbanSection {
                configurations: [(
                    "default".to_string(),
                    BoardConfiguration {
                        columns: columns.iter().map(|c| (*c).to_string()).collect(),
                        ..BoardConfiguration::default()
                    },
                )]
                .into_iter()
                .collect(),
            },
            issues_dir: PathBuf::from("docs/issues"),
            ..Config::default()
        }
    }

    fn triaged(columns: &[&str], entry: &str) -> Config {
        // Every board a `[triage]` section is checked against carries the column
        // that section declines into, and that column is mapped to `canceled` —
        // otherwise the validator would refuse `declined_status` and a test here
        // would be failing for the one field it does not mean to test.
        let mut with_declined: Vec<&str> = columns.to_vec();
        if !with_declined.contains(&"wontfix") {
            with_declined.push("wontfix");
        }
        let mut config = board(&with_declined);
        config
            .kanban
            .configurations
            .get_mut("default")
            .expect("board() installs one")
            .category_map
            .insert("wontfix".to_string(), "canceled".to_string());
        config.triage = Some(crate::triage::TriageConfig {
            entry_status: Some(entry.to_string()),
            declined_status: Some("wontfix".to_string()),
            ..crate::triage::TriageConfig::default()
        });
        config
    }

    #[test]
    fn triage_import_default_routes_into_the_entry_column() {
        let config = triaged(&["todo", "triage", "in progress", "done"], "triage");
        let router = resolve(&config, ImportPolicy::default()).expect("a valid section");
        let placed = router.place("opened", "todo");
        assert_eq!(
            placed.landed, "triage",
            "entry wins over the remote mapping"
        );
        assert_eq!(
            placed.requested, "todo",
            "the report still names what was asked"
        );
        assert!(placed.routed);
        assert_eq!(
            placed.report_clause(),
            " (triage entry: remote `opened` requested `todo`, landed `triage`)"
        );
    }

    #[test]
    fn triage_import_default_without_a_triage_section_leaves_the_mapping_alone() {
        let config = board(&["todo", "in progress", "done"]);
        let router = resolve(&config, ImportPolicy::default()).expect("no section to validate");
        let placed = router.place("opened", "todo");
        assert_eq!(placed.landed, "todo");
        assert!(!placed.routed);
        assert_eq!(
            placed.report_clause(),
            "",
            "nothing was decided, so nothing is announced and a legacy report is unchanged"
        );
    }

    #[test]
    fn triage_import_the_remote_policy_opts_out_even_where_an_entry_exists() {
        let config = triaged(&["todo", "triage", "done"], "triage");
        let router = resolve(&config, ImportPolicy::Remote).expect("an opt-out cannot fail");
        let placed = router.place("opened", "todo");
        assert_eq!(placed.landed, "todo", "the remote mapping stands");
        assert!(!placed.routed);
        assert_eq!(
            placed.report_clause(),
            " (import policy remote: remote `opened` requested `todo`, landed `todo`)",
            "an explicit opt-out is still reported, or it would be silent"
        );
    }

    #[test]
    fn triage_import_the_entry_policy_refuses_a_project_with_no_triage_section() {
        let config = board(&["todo", "in progress", "done"]);
        let err = resolve(&config, ImportPolicy::Entry).expect_err("unrepresentable");
        assert!(matches!(err, TixError::InvalidInput(_)), "{err:?}");
        assert_eq!(
            err.to_string(),
            "invalid input: `--triage-policy entry` needs a `[triage]` section in tix.toml, and \
             this project has none"
        );
    }

    #[test]
    fn triage_import_an_entry_column_the_board_lacks_is_refused_by_the_validator() {
        // `Config::load` would have refused this already; building it directly is
        // how a caller reaches the seam unvalidated, and the refusal must come
        // from the one validator rather than from a second opinion here — which
        // is why it is a `Config` error naming the field, not an `InvalidInput`
        // this module invented.
        let config = triaged(&["todo", "in progress", "done"], "nope");
        let err = resolve(&config, ImportPolicy::Entry).expect_err("no such column");
        assert!(matches!(err, TixError::Config(_)), "{err:?}");
        let text = err.to_string();
        assert!(text.contains("entry_status"), "{text}");
        assert!(text.contains("nope"), "{text}");
    }

    #[test]
    fn triage_import_parsing_refuses_anything_that_is_not_entry_or_remote() {
        assert_eq!(ImportPolicy::parse("entry").unwrap(), ImportPolicy::Entry);
        assert_eq!(
            ImportPolicy::parse("Remote").unwrap(),
            ImportPolicy::Remote,
            "the flag is case-insensitive, like every other value it takes"
        );
        assert_eq!(
            ImportPolicy::parse("default").unwrap_err().to_string(),
            "invalid input: unknown --triage-policy 'default' — use entry or remote",
            "the default is what an absent flag means, not a value it can take"
        );
        assert!(ImportPolicy::parse("triage").is_err());
        // A `BTreeMap` rather than literals: the vocabulary is closed, and a test
        // that names every member is how a fourth one gets noticed.
        let mut seen: BTreeMap<&str, ImportPolicy> = BTreeMap::new();
        for raw in ["entry", "remote"] {
            seen.insert(raw, ImportPolicy::parse(raw).unwrap());
        }
        assert_eq!(seen.len(), 2);
    }
}