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