#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Annotation {
pub kind: String,
pub args: Vec<String>,
}
pub fn parse_python_comment(line: &str) -> Option<Annotation> {
let hash_pos = line.find('#')?;
let after_hash = &line[hash_pos + 1..];
let trimmed = after_hash.trim_start();
let lower = trimmed.to_ascii_lowercase();
let after_keyword = lower.strip_prefix("repotoire")?;
let after_keyword = after_keyword.trim_start();
let body_lower = after_keyword.strip_prefix(':')?;
let consumed = lower.len() - body_lower.len();
let body = &trimmed[consumed..];
let body = body.trim_start();
if let Some(bracket_pos) = body.find('[') {
let kind = body[..bracket_pos].trim();
if kind.is_empty() {
return None;
}
let after_bracket = &body[bracket_pos + 1..];
let close_pos = after_bracket.find(']')?;
let args_str = &after_bracket[..close_pos];
let args: Vec<String> = if args_str.trim().is_empty() {
Vec::new()
} else {
args_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
Some(Annotation {
kind: kind.to_string(),
args,
})
} else {
let kind = body.split_whitespace().next()?.to_string();
if kind.is_empty() {
None
} else {
Some(Annotation {
kind,
args: Vec::new(),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ann(kind: &str, args: &[&str]) -> Annotation {
Annotation {
kind: kind.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn canonical_phase2a_protocol_required_parses() {
assert_eq!(
parse_python_comment("# repotoire: protocol-required[RFC7616]"),
Some(ann("protocol-required", &["RFC7616"])),
);
}
#[test]
fn canonical_phase2b_internal_path_parses() {
assert_eq!(
parse_python_comment("# repotoire: internal-path[validated-by-caller]"),
Some(ann("internal-path", &["validated-by-caller"])),
);
}
#[test]
fn canonical_phase2b_user_controlled_parses() {
assert_eq!(
parse_python_comment("# repotoire: user-controlled[GET-request]"),
Some(ann("user-controlled", &["GET-request"])),
);
}
#[test]
fn canonical_phase2c_tls_disabled_parses() {
assert_eq!(
parse_python_comment("# repotoire: tls-disabled[self-signed-dev-cert]"),
Some(ann("tls-disabled", &["self-signed-dev-cert"])),
);
}
#[test]
fn whitespace_variants_parse_identically() {
let want = Some(ann("tls-disabled", &["dev"]));
assert_eq!(parse_python_comment("# repotoire: tls-disabled[dev]"), want,);
assert_eq!(parse_python_comment("#repotoire:tls-disabled[dev]"), want);
assert_eq!(
parse_python_comment("# repotoire: tls-disabled [ dev ]"),
want,
);
assert_eq!(
parse_python_comment("# repotoire : tls-disabled[ dev ]"),
want,
);
}
#[test]
fn case_insensitive_keyword_only() {
assert_eq!(
parse_python_comment("# Repotoire: tls-disabled"),
Some(ann("tls-disabled", &[])),
);
assert_eq!(
parse_python_comment("# REPOTOIRE: tls-disabled"),
Some(ann("tls-disabled", &[])),
);
}
#[test]
fn case_preserved_in_kind_and_args() {
let parsed =
parse_python_comment("# repotoire: TLS-Disabled[Self-Signed]").expect("should parse");
assert_eq!(parsed.kind, "TLS-Disabled");
assert_eq!(parsed.args, vec!["Self-Signed"]);
}
#[test]
fn no_brackets_means_no_args() {
assert_eq!(
parse_python_comment("# repotoire: tls-disabled"),
Some(ann("tls-disabled", &[])),
);
}
#[test]
fn empty_brackets_means_empty_args() {
assert_eq!(
parse_python_comment("# repotoire: tls-disabled[]"),
Some(ann("tls-disabled", &[])),
);
}
#[test]
fn multiple_args_split_on_commas() {
assert_eq!(
parse_python_comment("# repotoire: user-controlled[GET, POST, body]"),
Some(ann("user-controlled", &["GET", "POST", "body"])),
);
}
#[test]
fn empty_args_in_list_are_dropped() {
assert_eq!(
parse_python_comment("# repotoire: kind[a,,b]"),
Some(ann("kind", &["a", "b"])),
);
}
#[test]
fn end_of_line_after_call_parses() {
assert_eq!(
parse_python_comment(
" return requests.get(url, verify=False) # repotoire: tls-disabled[trusted-dev]"
),
Some(ann("tls-disabled", &["trusted-dev"])),
);
}
#[test]
fn unrelated_comment_returns_none() {
assert_eq!(parse_python_comment("# TODO: fix this"), None);
assert_eq!(parse_python_comment("# noqa: E501"), None);
assert_eq!(parse_python_comment("# nosec"), None);
assert_eq!(parse_python_comment("# type: ignore"), None);
assert_eq!(parse_python_comment("open(p)"), None);
}
#[test]
fn empty_kind_returns_none() {
assert_eq!(parse_python_comment("# repotoire:"), None);
assert_eq!(parse_python_comment("# repotoire: "), None);
assert_eq!(parse_python_comment("# repotoire: [arg]"), None);
}
#[test]
fn unbalanced_bracket_returns_none() {
assert_eq!(
parse_python_comment("# repotoire: tls-disabled[trusted"),
None,
);
}
#[test]
fn unknown_kind_still_parses_forward_compat() {
assert_eq!(
parse_python_comment("# repotoire: future-kind-not-yet-shipped[arg1]"),
Some(ann("future-kind-not-yet-shipped", &["arg1"])),
);
}
#[test]
fn trailing_text_after_no_args_kind_stops_at_whitespace() {
assert_eq!(
parse_python_comment("# repotoire: tls-disabled some explanation"),
Some(ann("tls-disabled", &[])),
);
}
#[test]
fn empty_string_returns_none() {
assert_eq!(parse_python_comment(""), None);
}
#[test]
fn line_without_any_comment_returns_none() {
assert_eq!(parse_python_comment("open(p)"), None);
assert_eq!(parse_python_comment(" "), None);
}
}