1use std::fmt::Write as _;
6use std::path::Path;
7use std::process::ExitCode;
8
9use serde::Serialize;
10
11use crate::cli::{ProfileArgs, ProfileTarget};
12use crate::config::{self, ConfigDirective};
13use crate::error::RippyError;
14use crate::packages::{self, Package};
15
16pub fn run(args: &ProfileArgs) -> Result<ExitCode, RippyError> {
22 match &args.target {
23 ProfileTarget::List { json } => list_profiles(*json),
24 ProfileTarget::Show { name, json } => show_profile(name, *json),
25 ProfileTarget::Set { name, project } => set_profile(name, *project),
26 }
27}
28
29#[derive(Debug, Serialize)]
34struct ProfileListEntry {
35 name: String,
36 shield: String,
37 tagline: String,
38 active: bool,
39 #[serde(rename = "custom")]
40 is_custom: bool,
41}
42
43fn list_profiles(json: bool) -> Result<ExitCode, RippyError> {
44 let active = active_package_name();
45 let home = config::home_dir();
46 let packages = Package::all_available(home.as_deref());
47
48 if json {
49 let entries: Vec<ProfileListEntry> = packages
50 .iter()
51 .map(|p| ProfileListEntry {
52 name: p.name().to_string(),
53 shield: p.shield().to_string(),
54 tagline: p.tagline().to_string(),
55 active: active.as_deref() == Some(p.name()),
56 is_custom: p.is_custom(),
57 })
58 .collect();
59 let out = serde_json::to_string_pretty(&entries)
60 .map_err(|e| RippyError::Setup(format!("JSON error: {e}")))?;
61 println!("{out}");
62 return Ok(ExitCode::SUCCESS);
63 }
64
65 let (builtins, customs): (Vec<&Package>, Vec<&Package>) =
66 packages.iter().partition(|p| !p.is_custom());
67
68 for pkg in &builtins {
69 print_profile_line(pkg, active.as_deref());
70 }
71 if !customs.is_empty() {
72 println!();
73 println!("Custom packages:");
74 for pkg in &customs {
75 print_profile_line(pkg, active.as_deref());
76 }
77 }
78 Ok(ExitCode::SUCCESS)
79}
80
81fn print_profile_line(pkg: &Package, active: Option<&str>) {
82 let marker = if active == Some(pkg.name()) {
83 " (active)"
84 } else {
85 ""
86 };
87 println!(
88 " {:<12}[{}] {}{marker}",
89 pkg.name(),
90 pkg.shield(),
91 pkg.tagline(),
92 );
93}
94
95fn active_package_name() -> Option<String> {
97 let cwd = std::env::current_dir().unwrap_or_default();
98 let config = config::Config::load(&cwd, None).ok()?;
99 config.active_package.map(|p| p.name().to_string())
100}
101
102#[derive(Debug, Serialize)]
107struct ProfileShowOutput {
108 name: String,
109 shield: String,
110 tagline: String,
111 rules: Vec<RuleDisplay>,
112 git_style: Option<String>,
113 git_branches: Vec<BranchDisplay>,
114}
115
116#[derive(Debug, Serialize)]
117struct RuleDisplay {
118 action: String,
119 description: String,
120}
121
122#[derive(Debug, Serialize)]
123struct BranchDisplay {
124 pattern: String,
125 style: String,
126}
127
128fn show_profile(name: &str, json: bool) -> Result<ExitCode, RippyError> {
129 let home = config::home_dir();
130 let package = Package::resolve(name, home.as_deref())?;
131 let directives = packages::package_directives(&package)?;
132
133 let rules = extract_rule_displays(&directives);
134 let (git_style, git_branches) = extract_git_info(&package);
135
136 if json {
137 let output = ProfileShowOutput {
138 name: package.name().to_string(),
139 shield: package.shield().to_string(),
140 tagline: package.tagline().to_string(),
141 rules,
142 git_style,
143 git_branches,
144 };
145 let out = serde_json::to_string_pretty(&output)
146 .map_err(|e| RippyError::Setup(format!("JSON error: {e}")))?;
147 println!("{out}");
148 return Ok(ExitCode::SUCCESS);
149 }
150
151 println!("Package: {} [{}]", package.name(), package.shield());
152 println!(" \"{}\"", package.tagline());
153 println!();
154
155 if !rules.is_empty() {
156 println!(" Rules:");
157 for rule in &rules {
158 println!(" {:<6} {}", rule.action, rule.description);
159 }
160 println!();
161 }
162
163 if let Some(style) = &git_style {
164 let mut git_line = format!(" Git: {style}");
165 if !git_branches.is_empty() {
166 let _ = write!(git_line, " (");
167 for (i, b) in git_branches.iter().enumerate() {
168 if i > 0 {
169 let _ = write!(git_line, ", ");
170 }
171 let _ = write!(git_line, "{} on {}", b.style, b.pattern);
172 }
173 let _ = write!(git_line, ")");
174 }
175 println!("{git_line}");
176 }
177
178 Ok(ExitCode::SUCCESS)
179}
180
181fn extract_rule_displays(directives: &[ConfigDirective]) -> Vec<RuleDisplay> {
182 directives
183 .iter()
184 .filter_map(|d| {
185 if let ConfigDirective::Rule(r) = d {
186 Some(RuleDisplay {
187 action: r.decision.as_str().to_string(),
188 description: format_rule_description(r),
189 })
190 } else {
191 None
192 }
193 })
194 .collect()
195}
196
197fn format_rule_description(r: &crate::config::Rule) -> String {
198 if let Some(cmd) = &r.command {
200 let mut desc = cmd.clone();
201 if let Some(sub) = &r.subcommand {
202 desc = format!("{desc} {sub}");
203 } else if let Some(subs) = &r.subcommands {
204 desc = format!("{desc} {}", subs.join(", "));
205 }
206 if let Some(flags) = &r.flags {
207 desc = format!("{desc} [{}]", flags.join(", "));
208 }
209 if let Some(ac) = &r.args_contain {
210 desc = format!("{desc} (args contain \"{ac}\")");
211 }
212 if let Some(msg) = &r.message {
213 desc = format!("{desc} \"{msg}\"");
214 }
215 return desc;
216 }
217
218 let raw = r.pattern.raw();
219 r.message
220 .as_ref()
221 .map_or_else(|| raw.to_string(), |msg| format!("{raw} \"{msg}\""))
222}
223
224fn extract_git_info(package: &Package) -> (Option<String>, Vec<BranchDisplay>) {
225 let (own_style, own_branches) = parse_git_block(packages::package_toml(package));
228 if let Package::Custom(c) = package
229 && let Some(base_name) = &c.extends
230 && let Ok(base) = Package::parse(base_name)
231 {
232 let (base_style, base_branches) = parse_git_block(packages::package_toml(&base));
233 let style = own_style.or(base_style);
234 let branches = if own_branches.is_empty() {
235 base_branches
236 } else {
237 own_branches
238 };
239 return (style, branches);
240 }
241 (own_style, own_branches)
242}
243
244fn parse_git_block(source: &str) -> (Option<String>, Vec<BranchDisplay>) {
245 let config: crate::toml_config::TomlConfig = match toml::from_str(source) {
246 Ok(c) => c,
247 Err(_) => return (None, Vec::new()),
248 };
249 let Some(git) = config.git else {
250 return (None, Vec::new());
251 };
252 let branches = git
253 .branches
254 .iter()
255 .map(|b| BranchDisplay {
256 pattern: b.pattern.clone(),
257 style: b.style.clone(),
258 })
259 .collect();
260 (git.style, branches)
261}
262
263fn set_profile(name: &str, project: bool) -> Result<ExitCode, RippyError> {
268 let home = config::home_dir();
269 let _ = Package::resolve(name, home.as_deref())?;
270
271 let path = resolve_config_path(project)?;
272 write_package_setting(&path, name)?;
273
274 if project {
275 crate::trust::TrustGuard::before_write(&path).commit();
276 }
277 eprintln!("[rippy] Package set to \"{name}\" in {}", path.display());
278
279 Ok(ExitCode::SUCCESS)
280}
281
282fn resolve_config_path(project: bool) -> Result<std::path::PathBuf, RippyError> {
283 if project {
284 Ok(std::path::PathBuf::from(".rippy.toml"))
285 } else {
286 config::home_dir()
287 .map(|h| h.join(".rippy/config.toml"))
288 .ok_or_else(|| RippyError::Setup("could not determine home directory".into()))
289 }
290}
291
292pub fn write_package_setting(path: &Path, package_name: &str) -> Result<(), RippyError> {
302 if let Some(parent) = path.parent()
303 && !parent.as_os_str().is_empty()
304 {
305 std::fs::create_dir_all(parent).map_err(|e| {
306 RippyError::Setup(format!("could not create {}: {e}", parent.display()))
307 })?;
308 }
309
310 let existing = match std::fs::read_to_string(path) {
311 Ok(s) => s,
312 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
313 Err(e) => {
314 return Err(RippyError::Setup(format!(
315 "could not read {}: {e}",
316 path.display()
317 )));
318 }
319 };
320
321 let new_line = format!("package = \"{package_name}\"");
322 let content = update_package_in_content(&existing, &new_line);
323
324 std::fs::write(path, content)
325 .map_err(|e| RippyError::Setup(format!("could not write {}: {e}", path.display())))
326}
327
328fn is_package_setting_line(line: &str) -> bool {
329 let trimmed = line.trim();
330 trimmed.starts_with("package =") || trimmed.starts_with("package=")
331}
332
333fn update_package_in_content(existing: &str, new_line: &str) -> String {
334 if existing.lines().any(is_package_setting_line) {
336 return existing
337 .lines()
338 .map(|l| {
339 if is_package_setting_line(l) {
340 new_line.to_string()
341 } else {
342 l.to_string()
343 }
344 })
345 .collect::<Vec<_>>()
346 .join("\n")
347 + if existing.ends_with('\n') { "\n" } else { "" };
348 }
349
350 if existing.contains("[settings]") {
352 return existing
353 .lines()
354 .flat_map(|l| {
355 if l.trim() == "[settings]" {
356 vec![l.to_string(), new_line.to_string()]
357 } else {
358 vec![l.to_string()]
359 }
360 })
361 .collect::<Vec<_>>()
362 .join("\n")
363 + if existing.ends_with('\n') { "\n" } else { "" };
364 }
365
366 if existing.is_empty() {
368 format!("[settings]\n{new_line}\n")
369 } else {
370 format!("[settings]\n{new_line}\n\n{existing}")
371 }
372}
373
374#[cfg(test)]
375#[allow(clippy::unwrap_used)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn update_empty_file() {
381 let result = update_package_in_content("", "package = \"develop\"");
382 assert_eq!(result, "[settings]\npackage = \"develop\"\n");
383 }
384
385 #[test]
386 fn update_existing_package_line() {
387 let existing = "[settings]\npackage = \"review\"\n";
388 let result = update_package_in_content(existing, "package = \"develop\"");
389 assert!(result.contains("package = \"develop\""));
390 assert!(!result.contains("review"));
391 }
392
393 #[test]
394 fn update_settings_section_no_package() {
395 let existing = "[settings]\ndefault = \"ask\"\n";
396 let result = update_package_in_content(existing, "package = \"develop\"");
397 assert!(result.contains("[settings]"));
398 assert!(result.contains("package = \"develop\""));
399 assert!(result.contains("default = \"ask\""));
400 }
401
402 #[test]
403 fn update_no_settings_section() {
404 let existing = "[[rules]]\naction = \"allow\"\ncommand = \"ls\"\n";
405 let result = update_package_in_content(existing, "package = \"develop\"");
406 assert!(result.starts_with("[settings]\npackage = \"develop\""));
407 assert!(result.contains("[[rules]]"));
408 }
409
410 #[test]
411 fn update_does_not_clobber_similar_keys() {
412 let existing = "[settings]\npackage_version = \"1.0\"\ndefault = \"ask\"\n";
414 let result = update_package_in_content(existing, "package = \"develop\"");
415 assert!(
416 result.contains("package_version = \"1.0\""),
417 "package_version should be preserved, got: {result}"
418 );
419 assert!(result.contains("package = \"develop\""));
420 }
421
422 #[test]
423 fn update_handles_no_space_before_equals() {
424 let existing = "[settings]\npackage=\"review\"\n";
425 let result = update_package_in_content(existing, "package = \"develop\"");
426 assert!(result.contains("package = \"develop\""));
427 assert!(!result.contains("review"));
428 }
429
430 #[test]
431 fn write_package_creates_file() {
432 let dir = tempfile::tempdir().unwrap();
433 let path = dir.path().join("config.toml");
434 write_package_setting(&path, "develop").unwrap();
435
436 let content = std::fs::read_to_string(&path).unwrap();
437 assert!(content.contains("package = \"develop\""));
438 assert!(content.contains("[settings]"));
439 }
440
441 #[test]
442 fn write_package_updates_existing() {
443 let dir = tempfile::tempdir().unwrap();
444 let path = dir.path().join("config.toml");
445 std::fs::write(&path, "[settings]\npackage = \"review\"\n").unwrap();
446
447 write_package_setting(&path, "autopilot").unwrap();
448
449 let content = std::fs::read_to_string(&path).unwrap();
450 assert!(content.contains("package = \"autopilot\""));
451 assert!(!content.contains("review"));
452 }
453}