use crate::linter::{Diagnostic, LintResult, Severity, Span};
fn check_label_format(
trimmed: &str,
line_num: usize,
label_value: &mut String,
result: &mut LintResult,
) {
if let Some(start) = trimmed.find("<string>") {
if let Some(end) = trimmed.find("</string>") {
*label_value = trimmed[start + 8..end].to_string();
if !label_value.contains('.') {
let span = Span::new(line_num + 1, 1, line_num + 1, trimmed.len().min(80));
let diag = Diagnostic::new(
"LAUNCHD001",
Severity::Warning,
format!(
"Label '{}' should use reverse-domain format (e.g., com.example.daemon) (F077)",
label_value
),
span,
);
result.add(diag);
}
}
}
}
fn emit_post_checks(
result: &mut LintResult,
has_label: bool,
has_program: bool,
has_program_arguments: bool,
program_line: usize,
) {
if has_program && has_program_arguments {
let span = Span::new(program_line, 1, program_line, 80);
result.add(Diagnostic::new(
"LAUNCHD001",
Severity::Warning,
"Both Program and ProgramArguments specified - use one or the other (F078)".to_string(),
span,
));
}
if !has_label {
result.add(Diagnostic::new(
"LAUNCHD001",
Severity::Error,
"Missing required Label key in plist (F077)".to_string(),
Span::new(1, 1, 1, 1),
));
}
if !has_program_arguments && !has_program {
result.add(Diagnostic::new(
"LAUNCHD001",
Severity::Error,
"Missing required ProgramArguments or Program key (F078)".to_string(),
Span::new(1, 1, 1, 1),
));
}
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
if !source.contains("<?xml") && !source.contains("<plist") {
result.add(Diagnostic::new(
"LAUNCHD001",
Severity::Error,
"Missing plist XML declaration or plist element (F076)".to_string(),
Span::new(1, 1, 1, 1),
));
return result;
}
let mut has_label = false;
let mut has_program_arguments = false;
let mut has_program = false;
let mut label_value = String::new();
let mut program_line = 0;
for (line_num, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.contains("<key>Label</key>") {
has_label = true;
}
if has_label && label_value.is_empty() && trimmed.contains("<string>") {
check_label_format(trimmed, line_num, &mut label_value, &mut result);
}
if trimmed.contains("<key>ProgramArguments</key>") {
has_program_arguments = true;
}
if trimmed.contains("<key>Program</key>") {
has_program = true;
program_line = line_num + 1;
}
if trimmed == "<string></string>" {
let span = Span::new(line_num + 1, 1, line_num + 1, trimmed.len());
result.add(Diagnostic::new(
"LAUNCHD001",
Severity::Warning,
"Empty string value in plist (F076)".to_string(),
span,
));
}
}
emit_post_checks(
&mut result,
has_label,
has_program,
has_program_arguments,
program_line,
);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_F076_valid_plist() {
let plist = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.daemon</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/daemon</string>
</array>
</dict>
</plist>"#;
let result = check(plist);
assert!(
!result
.diagnostics
.iter()
.any(|d| d.severity == Severity::Error),
"F076: Valid plist should not have errors"
);
}
#[test]
fn test_F076_invalid_xml() {
let plist = "not xml at all";
let result = check(plist);
assert!(
result
.diagnostics
.iter()
.any(|d| d.message.contains("Missing plist")),
"F076: Invalid XML should be detected"
);
}
#[test]
fn test_F077_missing_label() {
let plist = r#"<?xml version="1.0"?>
<plist version="1.0">
<dict>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/true</string>
</array>
</dict>
</plist>"#;
let result = check(plist);
assert!(
result
.diagnostics
.iter()
.any(|d| d.message.contains("Missing required Label")),
"F077: Missing Label should be detected"
);
}
#[test]
fn test_F077_non_reverse_domain_label() {
let plist = r#"<?xml version="1.0"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>mydaemon</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/true</string>
</array>
</dict>
</plist>"#;
let result = check(plist);
assert!(
result
.diagnostics
.iter()
.any(|d| d.message.contains("reverse-domain")),
"F077: Non-reverse-domain Label should warn"
);
}
#[test]
fn test_F078_missing_program() {
let plist = r#"<?xml version="1.0"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.daemon</string>
</dict>
</plist>"#;
let result = check(plist);
assert!(
result
.diagnostics
.iter()
.any(|d| d.message.contains("ProgramArguments")),
"F078: Missing ProgramArguments should be detected"
);
}
#[test]
fn test_F078_both_program_and_arguments() {
let plist = r#"<?xml version="1.0"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.daemon</string>
<key>Program</key>
<string>/usr/bin/true</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/true</string>
</array>
</dict>
</plist>"#;
let result = check(plist);
assert!(
result
.diagnostics
.iter()
.any(|d| d.message.contains("Both Program and ProgramArguments")),
"F078: Both Program and ProgramArguments should warn"
);
}
}