1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
//! IDEM003: Non-idempotent ln
//!
//! **Rule**: Detect `ln -s` without removing existing symlink first
//!
//! **Why this matters**:
//! `ln -s` fails if symlink exists, making scripts non-idempotent.
//! Re-running the script will fail instead of succeeding.
//!
//! **Auto-fix**: Suggest prepending `rm -f`
//!
//! ## Examples
//!
//! ❌ **BAD** (non-idempotent):
//! ```bash
//! ln -s /app/releases/v1.0 /app/current
//! ```
//!
//! ✅ **GOOD** (idempotent):
//! ```bash
//! rm -f /app/current && ln -s /app/releases/v1.0 /app/current
//! # OR use -f flag (force):
//! ln -sf /app/releases/v1.0 /app/current
//! ln -sfn /app/releases/v1.0 /app/current
//! ```
use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
use regex::Regex;
/// Check for ln -s without rm -f first
static LN_PATTERN: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"\bln\s+(-[a-z]*s[a-z]*)\s").unwrap());
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
// Match ln command with -s flag but WITHOUT -f flag
// This regex captures ln with -s but excludes idempotent variants:
// - ln -sf, ln -sfn (combined flags with f)
// - ln -fs, ln -fns (combined flags with f first)
// - ln -s ... -f (separate -f flag)
let ln_pattern = &*LN_PATTERN;
for (line_num, line) in source.lines().enumerate() {
// Skip if line has rm -f (already safe)
if line.contains("rm -f") {
continue;
}
// Check for ln -s pattern
if let Some(caps) = ln_pattern.captures(line) {
let flags = caps.get(1).map_or("", |m| m.as_str());
// Skip if -f flag is present (makes it idempotent)
// -f can be in combined flags like -sf, -sfn, -fs, -fns
// or as a separate flag later in the command
if flags.contains('f') || line.contains(" -f") {
continue;
}
if let Some(col) = line.find("ln ") {
let span = Span::new(line_num + 1, col + 1, line_num + 1, col + 6);
let fix = Fix::new_unsafe(vec![
"Option 1: ln -sfn /source /target (force + no-dereference, most portable)"
.to_string(),
"Option 2: ln -sf /source /target (force, may follow existing symlinks)"
.to_string(),
"Option 3: rm -f /target && ln -s /source /target".to_string(),
]);
let diag = Diagnostic::new(
"IDEM003",
Severity::Warning,
"Non-idempotent ln - requires manual fix (UNSAFE)",
span,
)
.with_fix(fix);
result.add(diag);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_IDEM003_detects_ln_without_rm() {
let script = "ln -s /app/releases/v1.0 /app/current";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "IDEM003");
assert_eq!(diag.severity, Severity::Warning);
}
#[test]
fn test_IDEM003_no_warning_with_rm() {
let script = "rm -f /app/current && ln -s /app/releases/v1.0 /app/current";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_IDEM003_no_warning_with_force_flag() {
// ln -sf is idempotent (force flag removes existing)
let script = "ln -sf /app/releases/v1.0 /app/current";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0, "ln -sf should be idempotent");
// ln -sfn is also idempotent (force + no-dereference)
let script = "ln -sfn /raid/target /src/target";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0, "ln -sfn should be idempotent");
// ln -fs (f before s) is also idempotent
let script = "ln -fs /app/releases/v1.0 /app/current";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0, "ln -fs should be idempotent");
// ln -nfs (multiple flags with f) is also idempotent
let script = "ln -nfs /app/releases/v1.0 /app/current";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0, "ln -nfs should be idempotent");
}
#[test]
fn test_IDEM003_no_warning_with_separate_force_flag() {
// ln -s ... -f (separate -f flag)
let script = "ln -s /app/releases/v1.0 /app/current -f";
let result = check(script);
assert_eq!(
result.diagnostics.len(),
0,
"ln -s with separate -f should be idempotent"
);
}
#[test]
fn test_IDEM003_provides_fix() {
let script = "ln -s /src /dst";
let result = check(script);
assert!(result.diagnostics[0].fix.is_some());
let fix = result.diagnostics[0].fix.as_ref().unwrap();
// UNSAFE fix: no automatic replacement, provides suggestions
assert_eq!(fix.replacement, "");
assert!(fix.is_unsafe());
assert!(!fix.suggested_alternatives.is_empty());
// Verify suggestions mention ln -sfn as the preferred option
assert!(fix
.suggested_alternatives
.iter()
.any(|s| s.contains("-sfn")));
}
#[test]
fn test_IDEM003_detects_ln_sn_without_force() {
// ln -sn (symbolic + no-dereference but NO force) is NOT idempotent
let script = "ln -sn /app/releases/v1.0 /app/current";
let result = check(script);
assert_eq!(
result.diagnostics.len(),
1,
"ln -sn without -f should trigger warning"
);
}
}