Skip to main content

gitgraph_core/
actions.rs

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(&param.id)
286                || placeholders.contains_key(&format!("${}", param.id))
287            {
288                continue;
289            }
290            let value = match expand_placeholders(&param.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}