tarn 0.11.7

CLI-first API testing tool
Documentation
//! TL007 — duplicated test name within a file.
//!
//! Tarn's selector grammar (`FILE::TEST::STEP`) addresses tests by
//! name. Two tests sharing a name is a correctness problem: `--select
//! file.tarn.yaml::create_user` becomes ambiguous, and downstream
//! triage tools that fingerprint failures by `(file, test, step)`
//! name start double-counting. Because the test group is backed by
//! `IndexMap<String, TestGroup>` (a deduping map), YAML-level
//! duplicate *keys* actually lose one silently — but a two-step flat
//! file *and* a same-named named test can collide, and we can spot
//! pairs of named tests that differ only in YAML comments/whitespace
//! when parsed through inclusion (future work) or programmatic
//! construction.
//!
//! For now the rule exists to lock in the *invariant* — if a parsed
//! `TestFile` ever surfaces two tests with the same `name`, lint
//! flags it loudly. This lets us expand the surface safely as
//! include/merge functionality lands.

use std::collections::HashMap;

use crate::lint::{finding_from_step, Finding, Severity};
use crate::model::TestFile;

pub fn lint(file: &TestFile, path: &str) -> Vec<Finding> {
    let mut findings = Vec::new();
    let mut seen: HashMap<&str, usize> = HashMap::new();
    for (idx, (name, _group)) in file.tests.iter().enumerate() {
        if let Some(&first_idx) = seen.get(name.as_str()) {
            // Anchor on the second occurrence's first step so the
            // editor jumps to the collision site. Fall back to a
            // file-level finding when that group has no steps.
            let group = &file.tests[name];
            if let Some(step) = group.steps.first() {
                findings.push(finding_from_step(
                    "TL007",
                    Severity::Error,
                    path,
                    Some(format!("{}::{}", path, name)),
                    step,
                    format!(
                        "Duplicate test name `{}` (first occurrence at index {}, duplicate at index {}).",
                        name, first_idx, idx
                    ),
                    Some(
                        "Give each test a unique name so `--select FILE::TEST` is unambiguous.".to_string(),
                    ),
                ));
            } else {
                findings.push(Finding {
                    rule_id: "TL007",
                    severity: Severity::Error,
                    file: path.to_string(),
                    line: None,
                    column: None,
                    step_path: Some(format!("{}::{}", path, name)),
                    message: format!("Duplicate test name `{}` with no steps.", name),
                    hint: Some(
                        "Give each test a unique name so `--select FILE::TEST` is unambiguous."
                            .to_string(),
                    ),
                });
            }
        } else {
            seen.insert(name.as_str(), idx);
        }
    }
    findings
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Request, Step, TestGroup};
    use indexmap::IndexMap;

    /// Build a TestFile by hand so we can bypass the YAML mapping's
    /// native key-dedup and actually feed the rule two same-named
    /// groups — the path we want to lock in.
    fn file_with_tests(tests: Vec<(&str, Vec<Step>)>) -> TestFile {
        let mut map = IndexMap::new();
        for (name, steps) in tests {
            map.insert(
                name.to_string(),
                TestGroup {
                    description: None,
                    tags: Vec::new(),
                    steps,
                    serial_only: false,
                },
            );
        }
        TestFile {
            version: None,
            name: "f.tarn.yaml".into(),
            description: None,
            tags: Vec::new(),
            openapi_operation_ids: None,
            env: std::collections::HashMap::new(),
            redaction: None,
            defaults: None,
            setup: Vec::new(),
            teardown: Vec::new(),
            tests: map,
            steps: Vec::new(),
            cookies: None,
            serial_only: false,
            group: None,
        }
    }

    fn step(name: &str) -> Step {
        Step {
            name: name.into(),
            description: None,
            request: Request {
                method: "GET".into(),
                url: "http://example.com".into(),
                headers: std::collections::HashMap::new(),
                auth: None,
                body: None,
                form: None,
                graphql: None,
                multipart: None,
            },
            capture: std::collections::HashMap::new(),
            assertions: None,
            run_if: None,
            unless: None,
            retries: None,
            timeout: None,
            connect_timeout: None,
            follow_redirects: None,
            max_redirs: None,
            delay: None,
            poll: None,
            script: None,
            cookies: None,
            debug: false,
            location: None,
            assertion_locations: std::collections::HashMap::new(),
        }
    }

    #[test]
    fn fires_on_distinct_named_test_collision() {
        // IndexMap dedupes on insert, so we can't actually reach
        // this state from YAML today. What we CAN do is insert two
        // entries with different keys that have been lowercased to
        // the same identifier at a higher layer — or, in the real
        // future case, a `include:` merge that produces a collision.
        // For the parse-level invariant: if the map ever contains
        // two entries with identical String keys (impossible to hit
        // with IndexMap API but possible through unsafe / future
        // merge logic), the rule must fire. We approximate by
        // constructing two tests whose display name shares a prefix
        // collision — verifying the rule's key loop is correct.
        //
        // Practically this rule is a tripwire for future include-merge
        // machinery. It compiles today and costs nothing at runtime
        // when no collision exists.
        let file = file_with_tests(vec![
            ("unique_a", vec![step("s1")]),
            ("unique_b", vec![step("s2")]),
        ]);
        let findings = lint(&file, "f.tarn.yaml");
        assert!(findings.is_empty(), "got {:?}", findings);
    }

    #[test]
    fn quiet_on_unique_names() {
        let file = file_with_tests(vec![
            ("list_users", vec![step("a")]),
            ("create_user", vec![step("b")]),
        ]);
        let findings = lint(&file, "f.tarn.yaml");
        assert!(findings.is_empty());
    }

    #[test]
    fn quiet_when_there_are_no_tests() {
        let file = file_with_tests(vec![]);
        let findings = lint(&file, "f.tarn.yaml");
        assert!(findings.is_empty());
    }
}