npm_run_scripts/package/
descriptions.rs

1//! Script description extraction from various sources in package.json.
2//!
3//! Descriptions can come from multiple sources, in priority order:
4//! 1. `scripts-info` object (highest priority)
5//! 2. `ntl.descriptions` object
6//! 3. `// comment` prefixes in scripts object
7//!
8//! If no description is found, the command itself is used as a fallback.
9
10use std::collections::HashMap;
11
12use super::types::{Package, Script};
13
14/// Extract descriptions from all available sources in a Package.
15///
16/// Sources are checked in priority order:
17/// 1. `scripts-info` - Direct descriptions object
18/// 2. `ntl.descriptions` - NTL tool format
19/// 3. `// comments` - Comment keys in scripts object
20///
21/// # Examples
22///
23/// ```ignore
24/// let package = parse_package_json(content)?;
25/// let descriptions = extract_descriptions(&package);
26/// ```
27pub fn extract_descriptions(package: &Package) -> HashMap<String, String> {
28    let mut descriptions = HashMap::new();
29
30    // Priority 1: scripts-info (highest priority)
31    for (name, desc) in &package.scripts_info {
32        descriptions.insert(name.clone(), desc.clone());
33    }
34
35    // Priority 2: ntl.descriptions
36    if let Some(ntl) = &package.ntl {
37        for (name, desc) in &ntl.descriptions {
38            descriptions
39                .entry(name.clone())
40                .or_insert_with(|| desc.clone());
41        }
42    }
43
44    // Priority 3: // comments in scripts
45    // Look for keys like "//scriptName", "// scriptName", "//scriptName//"
46    for (key, value) in &package.scripts {
47        if let Some(script_name) = parse_comment_key(key) {
48            descriptions
49                .entry(script_name)
50                .or_insert_with(|| value.clone());
51        }
52    }
53
54    descriptions
55}
56
57/// Parse a comment key to extract the script name.
58///
59/// Handles various comment formats:
60/// - `//scriptName` -> `scriptName`
61/// - `// scriptName` -> `scriptName`
62/// - `//scriptName//` -> `scriptName`
63/// - `// scriptName //` -> `scriptName`
64fn parse_comment_key(key: &str) -> Option<String> {
65    if !key.starts_with("//") {
66        return None;
67    }
68
69    // Remove leading //
70    let stripped = key.strip_prefix("//")?;
71
72    // Remove trailing // if present
73    let stripped = stripped.strip_suffix("//").unwrap_or(stripped);
74
75    // Trim whitespace
76    let script_name = stripped.trim();
77
78    if script_name.is_empty() {
79        return None;
80    }
81
82    Some(script_name.to_string())
83}
84
85/// Get the display description for a script.
86///
87/// Returns the script's description if set, otherwise falls back to
88/// displaying the command with a `$` prefix.
89///
90/// # Examples
91///
92/// ```
93/// use npm_run_scripts::package::{Script, get_description};
94///
95/// let script = Script::with_description("dev", "vite", "Start dev server");
96/// assert_eq!(get_description(&script), "Start dev server");
97///
98/// let script_no_desc = Script::new("build", "vite build");
99/// assert_eq!(get_description(&script_no_desc), "$ vite build");
100/// ```
101pub fn get_description(script: &Script) -> String {
102    script
103        .description()
104        .map(|d| d.to_string())
105        .unwrap_or_else(|| format!("$ {}", script.command()))
106}
107
108/// Get a short description, truncated if necessary.
109///
110/// # Arguments
111///
112/// * `script` - The script to get description for
113/// * `max_len` - Maximum length before truncation
114pub fn get_short_description(script: &Script, max_len: usize) -> String {
115    let desc = get_description(script);
116    if desc.len() <= max_len {
117        desc
118    } else {
119        format!("{}...", &desc[..max_len.saturating_sub(3)])
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_get_description_with_desc() {
129        let script = Script::with_description("dev", "vite", "Start dev server");
130        assert_eq!(get_description(&script), "Start dev server");
131    }
132
133    #[test]
134    fn test_get_description_fallback() {
135        let script = Script::new("dev", "vite");
136        assert_eq!(get_description(&script), "$ vite");
137    }
138
139    #[test]
140    fn test_get_short_description() {
141        let script = Script::with_description(
142            "dev",
143            "vite",
144            "This is a very long description that should be truncated",
145        );
146        let short = get_short_description(&script, 20);
147        assert_eq!(short, "This is a very lo...");
148        assert!(short.len() <= 20);
149    }
150
151    #[test]
152    fn test_get_short_description_no_truncation() {
153        let script = Script::with_description("dev", "vite", "Short desc");
154        let short = get_short_description(&script, 20);
155        assert_eq!(short, "Short desc");
156    }
157
158    #[test]
159    fn test_parse_comment_key_standard() {
160        assert_eq!(parse_comment_key("//dev"), Some("dev".to_string()));
161        assert_eq!(parse_comment_key("//build"), Some("build".to_string()));
162    }
163
164    #[test]
165    fn test_parse_comment_key_with_space() {
166        assert_eq!(parse_comment_key("// dev"), Some("dev".to_string()));
167        assert_eq!(parse_comment_key("//  build"), Some("build".to_string()));
168    }
169
170    #[test]
171    fn test_parse_comment_key_with_trailing_slashes() {
172        assert_eq!(parse_comment_key("//dev//"), Some("dev".to_string()));
173        assert_eq!(parse_comment_key("// build //"), Some("build".to_string()));
174    }
175
176    #[test]
177    fn test_parse_comment_key_non_comment() {
178        assert_eq!(parse_comment_key("dev"), None);
179        assert_eq!(parse_comment_key("/dev"), None);
180    }
181
182    #[test]
183    fn test_parse_comment_key_empty() {
184        assert_eq!(parse_comment_key("//"), None);
185        assert_eq!(parse_comment_key("//  "), None);
186        assert_eq!(parse_comment_key("////"), None);
187    }
188
189    #[test]
190    fn test_extract_descriptions_scripts_info() {
191        let package = Package {
192            scripts_info: [("dev".to_string(), "Start development".to_string())]
193                .into_iter()
194                .collect(),
195            ..Default::default()
196        };
197
198        let descriptions = extract_descriptions(&package);
199        assert_eq!(
200            descriptions.get("dev"),
201            Some(&"Start development".to_string())
202        );
203    }
204
205    #[test]
206    fn test_extract_descriptions_ntl() {
207        use super::super::types::NtlConfig;
208
209        let package = Package {
210            ntl: Some(NtlConfig {
211                descriptions: [("test".to_string(), "Run tests".to_string())]
212                    .into_iter()
213                    .collect(),
214            }),
215            ..Default::default()
216        };
217
218        let descriptions = extract_descriptions(&package);
219        assert_eq!(descriptions.get("test"), Some(&"Run tests".to_string()));
220    }
221
222    #[test]
223    fn test_extract_descriptions_comments() {
224        let package = Package {
225            scripts: [
226                ("//lint".to_string(), "Run ESLint".to_string()),
227                ("lint".to_string(), "eslint .".to_string()),
228            ]
229            .into_iter()
230            .collect(),
231            ..Default::default()
232        };
233
234        let descriptions = extract_descriptions(&package);
235        assert_eq!(descriptions.get("lint"), Some(&"Run ESLint".to_string()));
236    }
237
238    #[test]
239    fn test_extract_descriptions_priority() {
240        use super::super::types::NtlConfig;
241
242        // scripts-info should take priority over ntl and comments
243        let package = Package {
244            scripts: [
245                ("//dev".to_string(), "Comment description".to_string()),
246                ("dev".to_string(), "vite".to_string()),
247            ]
248            .into_iter()
249            .collect(),
250            scripts_info: [("dev".to_string(), "Scripts-info description".to_string())]
251                .into_iter()
252                .collect(),
253            ntl: Some(NtlConfig {
254                descriptions: [("dev".to_string(), "NTL description".to_string())]
255                    .into_iter()
256                    .collect(),
257            }),
258            ..Default::default()
259        };
260
261        let descriptions = extract_descriptions(&package);
262        assert_eq!(
263            descriptions.get("dev"),
264            Some(&"Scripts-info description".to_string())
265        );
266    }
267
268    #[test]
269    fn test_extract_descriptions_ntl_over_comments() {
270        use super::super::types::NtlConfig;
271
272        // ntl should take priority over comments
273        let package = Package {
274            scripts: [
275                ("//build".to_string(), "Comment description".to_string()),
276                ("build".to_string(), "vite build".to_string()),
277            ]
278            .into_iter()
279            .collect(),
280            ntl: Some(NtlConfig {
281                descriptions: [("build".to_string(), "NTL description".to_string())]
282                    .into_iter()
283                    .collect(),
284            }),
285            ..Default::default()
286        };
287
288        let descriptions = extract_descriptions(&package);
289        assert_eq!(
290            descriptions.get("build"),
291            Some(&"NTL description".to_string())
292        );
293    }
294}