1use std::collections::{HashMap, HashSet};
2use std::sync::OnceLock;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{GitLgError, Result};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum ActionScope {
11 Global,
12 BranchDrop,
13 Commit,
14 Commits,
15 Stash,
16 Tag,
17 Branch,
18}
19
20impl ActionScope {
21 pub fn as_str(self) -> &'static str {
22 match self {
23 Self::Global => "global",
24 Self::BranchDrop => "branch-drop",
25 Self::Commit => "commit",
26 Self::Commits => "commits",
27 Self::Stash => "stash",
28 Self::Tag => "tag",
29 Self::Branch => "branch",
30 }
31 }
32
33 pub fn all() -> &'static [Self] {
34 &[
35 Self::Global,
36 Self::BranchDrop,
37 Self::Commit,
38 Self::Commits,
39 Self::Stash,
40 Self::Tag,
41 Self::Branch,
42 ]
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ActionOption {
48 #[serde(default)]
49 pub id: String,
50 #[serde(default)]
51 pub title: String,
52 #[serde(default)]
53 pub flag: String,
54 #[serde(default)]
55 pub default_active: bool,
56 #[serde(default)]
57 pub info: Option<String>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct ActionParam {
62 #[serde(default)]
63 pub id: String,
64 #[serde(default)]
65 pub default_value: String,
66 #[serde(default)]
67 pub placeholder: Option<String>,
68 #[serde(default)]
69 pub multiline: bool,
70 #[serde(default)]
71 pub readonly: bool,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ActionTemplate {
76 #[serde(default)]
77 pub id: String,
78 #[serde(default = "default_action_scope")]
79 pub scope: ActionScope,
80 #[serde(default)]
81 pub title: String,
82 #[serde(default)]
83 pub icon: Option<String>,
84 #[serde(default)]
85 pub description: String,
86 #[serde(default)]
87 pub info: Option<String>,
88 #[serde(default)]
89 pub args: Vec<String>,
90 #[serde(default)]
91 pub raw_args: String,
92 #[serde(default)]
93 pub shell_script: bool,
94 #[serde(default)]
95 pub params: Vec<ActionParam>,
96 #[serde(default)]
97 pub options: Vec<ActionOption>,
98 #[serde(default)]
99 pub immediate: bool,
100 #[serde(default)]
101 pub ignore_errors: bool,
102 #[serde(default)]
103 pub allow_non_zero_exit: bool,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
107pub struct ActionContext {
108 #[serde(default)]
109 pub branch_display_name: Option<String>,
110 #[serde(default)]
111 pub branch_name: Option<String>,
112 #[serde(default)]
113 pub local_branch_name: Option<String>,
114 #[serde(default)]
115 pub branch_id: Option<String>,
116 #[serde(default)]
117 pub source_branch_name: Option<String>,
118 #[serde(default)]
119 pub target_branch_name: Option<String>,
120 #[serde(default)]
121 pub commit_hash: Option<String>,
122 #[serde(default)]
123 pub commit_hashes: Vec<String>,
124 #[serde(default)]
125 pub commit_body: Option<String>,
126 #[serde(default)]
127 pub stash_name: Option<String>,
128 #[serde(default)]
129 pub tag_name: Option<String>,
130 #[serde(default)]
131 pub remote_name: Option<String>,
132 #[serde(default)]
133 pub default_remote_name: Option<String>,
134 #[serde(default)]
135 pub additional_placeholders: HashMap<String, String>,
136}
137
138impl ActionContext {
139 pub fn to_placeholder_map(&self) -> HashMap<String, String> {
140 let mut out = HashMap::new();
141 if let Some(v) = &self.branch_display_name {
142 out.insert("BRANCH_DISPLAY_NAME".to_string(), v.clone());
143 }
144 if let Some(v) = &self.branch_name {
145 out.insert("BRANCH_NAME".to_string(), v.clone());
146 }
147 if let Some(v) = &self.local_branch_name {
148 out.insert("LOCAL_BRANCH_NAME".to_string(), v.clone());
149 }
150 if let Some(v) = &self.branch_id {
151 out.insert("BRANCH_ID".to_string(), v.clone());
152 }
153 if let Some(v) = &self.source_branch_name {
154 out.insert("SOURCE_BRANCH_NAME".to_string(), v.clone());
155 }
156 if let Some(v) = &self.target_branch_name {
157 out.insert("TARGET_BRANCH_NAME".to_string(), v.clone());
158 }
159 if let Some(v) = &self.commit_hash {
160 out.insert("COMMIT_HASH".to_string(), v.clone());
161 }
162 if !self.commit_hashes.is_empty() {
163 out.insert("COMMIT_HASHES".to_string(), self.commit_hashes.join(" "));
164 }
165 if let Some(v) = &self.commit_body {
166 out.insert("COMMIT_BODY".to_string(), v.clone());
167 }
168 if let Some(v) = &self.stash_name {
169 out.insert("STASH_NAME".to_string(), v.clone());
170 }
171 if let Some(v) = &self.tag_name {
172 out.insert("TAG_NAME".to_string(), v.clone());
173 }
174 if let Some(v) = &self.remote_name {
175 out.insert("REMOTE_NAME".to_string(), v.clone());
176 }
177 if let Some(v) = &self.default_remote_name {
178 out.insert("DEFAULT_REMOTE_NAME".to_string(), v.clone());
179 } else if let Some(v) = &self.remote_name {
180 out.insert("DEFAULT_REMOTE_NAME".to_string(), v.clone());
181 }
182 out.extend(self.additional_placeholders.clone());
183 out
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct ActionRequest {
189 pub template_id: String,
190 pub params: HashMap<String, String>,
191 pub enabled_options: HashSet<String>,
192 pub context: ActionContext,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct ResolvedAction {
197 pub id: String,
198 pub title: String,
199 pub scope: ActionScope,
200 pub args: Vec<String>,
201 pub shell_script: Option<String>,
202 pub command_line: String,
203 pub allow_non_zero_exit: bool,
204 pub ignore_errors: bool,
205}
206
207#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
208pub struct ActionCatalog {
209 pub templates: Vec<ActionTemplate>,
210}
211
212impl ActionCatalog {
213 pub fn with_defaults() -> Self {
214 static BUILTIN: OnceLock<ActionCatalog> = OnceLock::new();
215 BUILTIN
216 .get_or_init(|| {
217 let templates = parse_builtin_actions()
218 .expect("default-git-actions.json should be valid and parseable");
219 ActionCatalog { templates }
220 })
221 .clone()
222 }
223
224 pub fn find(&self, id: &str) -> Option<&ActionTemplate> {
225 if let Some(found) = self.templates.iter().find(|t| t.id == id) {
226 return Some(found);
227 }
228 if id.contains(':') {
229 return None;
230 }
231 let suffix = self
232 .templates
233 .iter()
234 .filter(|t| t.id.ends_with(&format!(":{id}")))
235 .min_by_key(|t| (t.shell_script, t.params.len(), t.args.len()));
236 if suffix.is_some() {
237 return suffix;
238 }
239
240 let title = self
241 .templates
242 .iter()
243 .filter(|t| sanitize_id_fragment(&t.title) == id)
244 .min_by_key(|t| (t.shell_script, t.params.len(), t.args.len()));
245 if title.is_some() {
246 return title;
247 }
248
249 self.templates
250 .iter()
251 .filter(|t| {
252 t.args
253 .first()
254 .is_some_and(|command| command.eq_ignore_ascii_case(id))
255 })
256 .min_by_key(|t| (t.shell_script, t.params.len(), t.args.len()))
257 }
258
259 pub fn templates_for_scope(&self, scope: ActionScope) -> Vec<&ActionTemplate> {
260 self.templates.iter().filter(|t| t.scope == scope).collect()
261 }
262
263 pub fn resolve(&self, request: ActionRequest) -> Result<ResolvedAction> {
264 self.resolve_with_lookup(request, |_placeholder| Ok(None))
265 }
266
267 pub fn resolve_with_lookup<F>(
268 &self,
269 request: ActionRequest,
270 lookup: F,
271 ) -> Result<ResolvedAction>
272 where
273 F: Fn(&str) -> Result<Option<String>>,
274 {
275 let template = self.find(&request.template_id).ok_or_else(|| {
276 GitLgError::State(format!(
277 "unknown action template id: {}",
278 request.template_id
279 ))
280 })?;
281
282 let mut placeholders = request.context.to_placeholder_map();
283 placeholders.extend(request.params);
284 for param in &template.params {
285 if placeholders.contains_key(¶m.id)
286 || placeholders.contains_key(&format!("${}", param.id))
287 {
288 continue;
289 }
290 let value = match expand_placeholders(¶m.default_value, &placeholders, &lookup) {
291 Ok(expanded) => expanded,
292 Err(GitLgError::MissingPlaceholder(_)) => param.default_value.clone(),
293 Err(e) => return Err(e),
294 };
295 placeholders.insert(param.id.clone(), value);
296 }
297 for (k, v) in numeric_placeholder_aliases(&placeholders) {
298 placeholders.insert(k, v);
299 }
300
301 let mut args = Vec::new();
302 for token in &template.args {
303 args.push(expand_placeholders(token, &placeholders, &lookup)?);
304 }
305 for option in &template.options {
306 if request.enabled_options.contains(&option.id)
307 || request.enabled_options.contains(&option.flag)
308 || option.default_active
309 {
310 for token in tokenize_args(&option.flag) {
311 args.push(expand_placeholders(&token, &placeholders, &lookup)?);
312 }
313 }
314 }
315
316 let mut command_line = if template.shell_script {
317 expand_placeholders(&template.raw_args, &placeholders, &lookup)?
318 } else {
319 args.join(" ")
320 };
321 if template.shell_script {
322 for option in &template.options {
323 if request.enabled_options.contains(&option.id)
324 || request.enabled_options.contains(&option.flag)
325 || option.default_active
326 {
327 let expanded = expand_placeholders(&option.flag, &placeholders, &lookup)?;
328 if !expanded.is_empty() {
329 command_line.push(' ');
330 command_line.push_str(&expanded);
331 }
332 }
333 }
334 }
335
336 Ok(ResolvedAction {
337 id: template.id.clone(),
338 title: template.title.clone(),
339 scope: template.scope,
340 args,
341 shell_script: template.shell_script.then_some(command_line.clone()),
342 command_line,
343 allow_non_zero_exit: template.allow_non_zero_exit,
344 ignore_errors: template.ignore_errors,
345 })
346 }
347}
348
349fn numeric_placeholder_aliases(values: &HashMap<String, String>) -> HashMap<String, String> {
350 let mut aliases = HashMap::new();
351 for (key, value) in values {
352 if key.chars().all(|c| c.is_ascii_digit()) {
353 aliases.insert(format!("${}", key), value.clone());
354 }
355 }
356 aliases
357}
358
359pub fn expand_placeholders<F>(
360 input: &str,
361 placeholders: &HashMap<String, String>,
362 lookup: &F,
363) -> Result<String>
364where
365 F: Fn(&str) -> Result<Option<String>>,
366{
367 let mut out = String::with_capacity(input.len());
368 let mut chars = input.chars().peekable();
369 while let Some(ch) = chars.next() {
370 if ch == '{' {
371 let mut key = String::new();
372 let mut closed = false;
373 for next in chars.by_ref() {
374 if next == '}' {
375 closed = true;
376 break;
377 }
378 key.push(next);
379 }
380 if !closed {
381 return Err(GitLgError::Parse(format!(
382 "unterminated placeholder in token {:?}",
383 input
384 )));
385 }
386 let value = if let Some(v) = placeholders.get(&key) {
387 v.clone()
388 } else if let Some(v) = lookup(&key)? {
389 v
390 } else {
391 return Err(GitLgError::MissingPlaceholder(key));
392 };
393 out.push_str(&value);
394 continue;
395 }
396
397 if ch == '$' {
398 let mut numeric = String::new();
399 while let Some(peek) = chars.peek() {
400 if peek.is_ascii_digit() {
401 numeric.push(*peek);
402 chars.next();
403 } else {
404 break;
405 }
406 }
407 if numeric.is_empty() {
408 out.push('$');
409 } else {
410 let key = format!("${}", numeric);
411 let value = placeholders
412 .get(&key)
413 .ok_or_else(|| GitLgError::MissingPlaceholder(key.clone()))?;
414 out.push_str(value);
415 }
416 continue;
417 }
418 out.push(ch);
419 }
420 Ok(out)
421}
422
423fn parse_builtin_actions() -> Result<Vec<ActionTemplate>> {
424 let raw: RawActionsFile = serde_json::from_str(include_str!("../default-git-actions.json"))
425 .map_err(|e| GitLgError::Parse(format!("invalid default actions json: {}", e)))?;
426
427 let mut out = Vec::new();
428 let groups = [
429 (ActionScope::Global, raw.actions_global),
430 (ActionScope::BranchDrop, raw.actions_branch_drop),
431 (ActionScope::Commit, raw.actions_commit),
432 (ActionScope::Commits, raw.actions_commits),
433 (ActionScope::Stash, raw.actions_stash),
434 (ActionScope::Tag, raw.actions_tag),
435 (ActionScope::Branch, raw.actions_branch),
436 ];
437
438 for (scope, actions) in groups {
439 for (index, raw_action) in actions.into_iter().enumerate() {
440 out.push(convert_raw_action(scope, index, raw_action));
441 }
442 }
443 Ok(out)
444}
445
446fn default_action_scope() -> ActionScope {
447 ActionScope::Global
448}
449
450fn convert_raw_action(scope: ActionScope, index: usize, raw: RawAction) -> ActionTemplate {
451 let raw_args = raw.args.unwrap_or_default();
452 let args = tokenize_args(&raw_args);
453 let title = choose_title(raw.title.as_deref(), raw.description.as_deref(), &args);
454 let id = format!(
455 "{}:{}:{}",
456 scope.as_str(),
457 index + 1,
458 sanitize_id_fragment(&title)
459 );
460 let params = raw
461 .params
462 .unwrap_or_default()
463 .into_iter()
464 .enumerate()
465 .map(|(idx, p)| convert_raw_param(idx, p))
466 .collect::<Vec<_>>();
467 let options = raw
468 .options
469 .unwrap_or_default()
470 .into_iter()
471 .enumerate()
472 .map(|(idx, o)| convert_raw_option(idx, o))
473 .collect::<Vec<_>>();
474
475 ActionTemplate {
476 id,
477 scope,
478 title,
479 icon: raw.icon,
480 description: raw.description.unwrap_or_default(),
481 info: raw.info,
482 args,
483 raw_args: raw_args.clone(),
484 shell_script: is_shell_script(&raw_args),
485 params,
486 options,
487 immediate: raw.immediate.unwrap_or(false),
488 ignore_errors: raw.ignore_errors.unwrap_or(false),
489 allow_non_zero_exit: raw.ignore_errors.unwrap_or(false),
490 }
491}
492
493fn convert_raw_param(index: usize, raw: RawParam) -> ActionParam {
494 match raw {
495 RawParam::Simple(value) => ActionParam {
496 id: (index + 1).to_string(),
497 default_value: value,
498 placeholder: None,
499 multiline: false,
500 readonly: false,
501 },
502 RawParam::Detailed {
503 value,
504 multiline,
505 placeholder,
506 readonly,
507 } => ActionParam {
508 id: (index + 1).to_string(),
509 default_value: value,
510 placeholder,
511 multiline: multiline.unwrap_or(false),
512 readonly: readonly.unwrap_or(false),
513 },
514 }
515}
516
517fn convert_raw_option(index: usize, raw: RawOption) -> ActionOption {
518 ActionOption {
519 id: sanitize_id_fragment(&format!("{}-{}", raw.value, index + 1)),
520 title: raw.value.clone(),
521 flag: raw.value,
522 default_active: raw.default_active.unwrap_or(false),
523 info: raw.info,
524 }
525}
526
527fn choose_title(title: Option<&str>, description: Option<&str>, args: &[String]) -> String {
528 let title = title.unwrap_or("").trim();
529 if !title.is_empty() {
530 return title.to_string();
531 }
532 if let Some(desc) = description {
533 let trimmed = desc.trim();
534 if !trimmed.is_empty() {
535 if let Some((prefix, _)) = trimmed.split_once('(') {
536 return prefix.trim().to_string();
537 }
538 return trimmed.to_string();
539 }
540 }
541 args.join(" ")
542}
543
544fn sanitize_id_fragment(text: &str) -> String {
545 let lowered = text.to_lowercase();
546 let mut out = String::with_capacity(lowered.len());
547 let mut prev_dash = false;
548 for ch in lowered.chars() {
549 if ch.is_ascii_alphanumeric() {
550 out.push(ch);
551 prev_dash = false;
552 } else if !prev_dash {
553 out.push('-');
554 prev_dash = true;
555 }
556 }
557 out.trim_matches('-').to_string()
558}
559
560fn tokenize_args(args: &str) -> Vec<String> {
561 if args.trim().is_empty() {
562 return Vec::new();
563 }
564 if let Some(tokens) = shlex::split(args) {
565 return tokens;
566 }
567 args.split_whitespace().map(ToString::to_string).collect()
568}
569
570fn is_shell_script(raw_args: &str) -> bool {
571 raw_args.contains("&&") || raw_args.contains("||") || raw_args.contains(';')
572}
573
574#[derive(Debug, Deserialize)]
575struct RawActionsFile {
576 #[serde(rename = "actions.global", default)]
577 actions_global: Vec<RawAction>,
578 #[serde(rename = "actions.branch-drop", default)]
579 actions_branch_drop: Vec<RawAction>,
580 #[serde(rename = "actions.commit", default)]
581 actions_commit: Vec<RawAction>,
582 #[serde(rename = "actions.commits", default)]
583 actions_commits: Vec<RawAction>,
584 #[serde(rename = "actions.stash", default)]
585 actions_stash: Vec<RawAction>,
586 #[serde(rename = "actions.tag", default)]
587 actions_tag: Vec<RawAction>,
588 #[serde(rename = "actions.branch", default)]
589 actions_branch: Vec<RawAction>,
590}
591
592#[derive(Debug, Deserialize)]
593struct RawAction {
594 #[serde(default)]
595 title: Option<String>,
596 #[serde(default)]
597 icon: Option<String>,
598 #[serde(default)]
599 description: Option<String>,
600 #[serde(default)]
601 info: Option<String>,
602 #[serde(default)]
603 args: Option<String>,
604 #[serde(default)]
605 params: Option<Vec<RawParam>>,
606 #[serde(default)]
607 options: Option<Vec<RawOption>>,
608 #[serde(default)]
609 immediate: Option<bool>,
610 #[serde(default)]
611 ignore_errors: Option<bool>,
612}
613
614#[derive(Debug, Deserialize)]
615#[serde(untagged)]
616enum RawParam {
617 Simple(String),
618 Detailed {
619 value: String,
620 multiline: Option<bool>,
621 placeholder: Option<String>,
622 readonly: Option<bool>,
623 },
624}
625
626#[derive(Debug, Deserialize)]
627struct RawOption {
628 value: String,
629 default_active: Option<bool>,
630 #[serde(default)]
631 info: Option<String>,
632}
633
634#[cfg(test)]
635mod tests {
636 use std::collections::{HashMap, HashSet};
637
638 use super::{
639 ActionCatalog, ActionContext, ActionRequest, ActionScope, ActionTemplate,
640 expand_placeholders,
641 };
642 use crate::error::Result;
643
644 #[test]
645 fn expands_named_and_indexed_placeholders() {
646 let mut values = HashMap::new();
647 values.insert("BRANCH_NAME".to_string(), "main".to_string());
648 values.insert("$1".to_string(), "feature".to_string());
649 let expanded =
650 expand_placeholders("merge {BRANCH_NAME} $1", &values, &|_| Ok(None)).expect("expands");
651 assert_eq!(expanded, "merge main feature");
652 }
653
654 #[test]
655 fn loads_builtin_scopes() {
656 let catalog = ActionCatalog::with_defaults();
657 assert!(!catalog.templates.is_empty());
658 for scope in ActionScope::all() {
659 assert!(
660 !catalog.templates_for_scope(*scope).is_empty(),
661 "scope {:?} should have templates",
662 scope
663 );
664 }
665 }
666
667 #[test]
668 fn resolves_dynamic_lookup_placeholder() {
669 let mut catalog = ActionCatalog::default();
670 catalog.templates.push(ActionTemplate {
671 id: "test:dynamic".to_string(),
672 scope: ActionScope::Global,
673 title: "dynamic".to_string(),
674 icon: None,
675 description: String::new(),
676 info: None,
677 args: vec![
678 "fetch".to_string(),
679 "{GIT_CONFIG:remote.pushDefault}".to_string(),
680 ],
681 raw_args: "fetch {GIT_CONFIG:remote.pushDefault}".to_string(),
682 shell_script: false,
683 params: vec![],
684 options: vec![],
685 immediate: false,
686 ignore_errors: false,
687 allow_non_zero_exit: false,
688 });
689 let request = ActionRequest {
690 template_id: "test:dynamic".to_string(),
691 params: HashMap::new(),
692 enabled_options: HashSet::new(),
693 context: ActionContext::default(),
694 };
695 let resolved = catalog.resolve_with_lookup(request, |key| -> Result<Option<String>> {
696 if key == "GIT_CONFIG:remote.pushDefault" {
697 Ok(Some("origin".to_string()))
698 } else {
699 Ok(None)
700 }
701 });
702 assert_eq!(resolved.expect("resolved").args, vec!["fetch", "origin"]);
703 }
704}