use crate::lint::{capture_jsonpath, finding_from_step, walk_steps, Finding, Severity};
use crate::model::TestFile;
pub fn lint(file: &TestFile, path: &str) -> Vec<Finding> {
let mut findings = Vec::new();
for (step_path, step) in walk_steps(file, path) {
for (name, spec) in &step.capture {
let Some(jsonpath) = capture_jsonpath(spec) else {
continue;
};
if !is_positional_index_path(jsonpath) {
continue;
}
if !url_looks_like_shared_list(&step.request.url) {
continue;
}
findings.push(finding_from_step(
"TL001",
Severity::Warning,
path,
Some(step_path.clone()),
step,
format!(
"Capture `{}` uses positional index `{}` on what looks like a shared list endpoint ({}).",
name, jsonpath, step.request.url
),
Some(
"Capturing from element 0 of a shared list depends on sort order and state from other tests. Filter the request or match by a stable attribute (e.g. `?email=...` or JSONPath predicate).".to_string(),
),
));
}
}
findings
}
pub(crate) fn is_positional_index_path(path: &str) -> bool {
let trimmed = path.trim();
for (i, c) in trimmed.char_indices() {
if c != '[' {
continue;
}
let rest = &trimmed[i + 1..];
if let Some(end) = rest.find(']') {
let inner = &rest[..end];
if !inner.is_empty() && inner.chars().all(|c| c.is_ascii_digit()) {
return true;
}
}
}
false
}
pub(crate) fn url_looks_like_shared_list(url: &str) -> bool {
let without_templates = strip_templates(url);
let (path, query) = split_url(&without_templates);
for segment in path.split('/') {
if segment.is_empty() {
continue;
}
if segment.starts_with(':') || (segment.starts_with('{') && segment.ends_with('}')) {
return false;
}
if looks_like_uuid(segment) {
return false;
}
}
if let Some(q) = query {
for pair in q.split('&') {
if let Some(key) = pair.split('=').next() {
let key = key.trim().to_ascii_lowercase();
const FILTER_KEYS: &[&str] =
&["filter", "name", "email", "where", "q", "search", "id"];
if FILTER_KEYS.contains(&key.as_str()) {
return false;
}
}
}
}
true
}
fn strip_templates(url: &str) -> String {
let mut out = String::with_capacity(url.len());
let bytes = url.as_bytes();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'{' && bytes[i + 1] == b'{' {
if let Some(end) = url[i + 2..].find("}}") {
i += 2 + end + 2;
continue;
} else {
out.push_str(&url[i..]);
return out;
}
}
out.push(bytes[i] as char);
i += 1;
}
out
}
fn split_url(url: &str) -> (&str, Option<&str>) {
match url.find('?') {
Some(idx) => (&url[..idx], Some(&url[idx + 1..])),
None => (url, None),
}
}
fn looks_like_uuid(segment: &str) -> bool {
let s = segment;
if s.len() != 36 {
return false;
}
let expected_dashes = [8, 13, 18, 23];
let bytes = s.as_bytes();
for (i, b) in bytes.iter().enumerate() {
if expected_dashes.contains(&i) {
if *b != b'-' {
return false;
}
} else if !b.is_ascii_hexdigit() {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_str;
use std::path::Path;
fn parse(source: &str) -> TestFile {
parse_str(source, Path::new("t.tarn.yaml")).expect("parse")
}
#[test]
fn fires_on_positional_capture_over_shared_list() {
let file = parse(
r#"
name: list
steps:
- name: get users
request:
method: GET
url: "http://example.com/users"
capture:
first_id: "$[0].id"
"#,
);
let findings = lint(&file, "t.tarn.yaml");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule_id, "TL001");
assert_eq!(findings[0].severity, Severity::Warning);
assert!(findings[0].message.contains("first_id"));
}
#[test]
fn silent_when_url_has_path_parameter() {
let file = parse(
r#"
name: scoped
steps:
- name: get roles
request:
method: GET
url: "http://example.com/users/{id}/roles"
capture:
first_role: "$.items[0].name"
"#,
);
let findings = lint(&file, "t.tarn.yaml");
assert!(findings.is_empty(), "expected no fire, got {:?}", findings);
}
#[test]
fn silent_when_query_filters_the_list() {
let file = parse(
r#"
name: filtered
steps:
- name: get user by email
request:
method: GET
url: "http://example.com/users?email=admin@test.com"
capture:
id: "$[0].id"
"#,
);
let findings = lint(&file, "t.tarn.yaml");
assert!(findings.is_empty(), "expected no fire, got {:?}", findings);
}
#[test]
fn url_heuristics_detect_uuid_segments() {
assert!(!url_looks_like_shared_list(
"http://example.com/users/550e8400-e29b-41d4-a716-446655440000"
));
assert!(url_looks_like_shared_list("http://example.com/users"));
assert!(!url_looks_like_shared_list(
"http://example.com/users?filter=admin"
));
}
}