Skip to main content

githops_graphui/
lib.rs

1use anyhow::Result;
2use axum::{
3    extract::{
4        ws::{Message, WebSocket, WebSocketUpgrade},
5        State,
6    },
7    http::header,
8    response::{Html, IntoResponse, Response},
9    routing::get,
10    Router,
11};
12use colored::Colorize;
13use std::collections::{BTreeMap, HashSet};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::SystemTime;
17
18use githops_core::config::{
19    Command, CommandCache, CommandEntry, Config, DefinitionEntry, GlobalCache, HookConfig, RefEntry,
20    CONFIG_FILE,
21};
22use githops_core::git::hooks_dir;
23use githops_core::hooks::ALL_HOOKS;
24
25// ---------------------------------------------------------------------------
26// Static UI assets (built by `pnpm run build` in ui/, embedded at compile time)
27// ---------------------------------------------------------------------------
28
29pub static INDEX_HTML: &str = include_str!("../ui/dist/index.html");
30pub static APP_JS: &str = include_str!("../ui/dist/assets/app.js");
31pub static APP_CSS: &str = include_str!("../ui/dist/assets/app.css");
32
33// ---------------------------------------------------------------------------
34// Entry point
35// ---------------------------------------------------------------------------
36
37pub fn run(open: bool) -> Result<()> {
38    tokio::runtime::Builder::new_multi_thread()
39        .enable_all()
40        .build()?
41        .block_on(async_run(open))
42}
43
44async fn async_run(open: bool) -> Result<()> {
45    let config_path = Arc::new(std::env::current_dir()?.join(CONFIG_FILE));
46
47    let listener = match tokio::net::TcpListener::bind("127.0.0.1:7890").await {
48        Ok(l) => l,
49        Err(_) => tokio::net::TcpListener::bind("127.0.0.1:0").await?,
50    };
51    let port = listener.local_addr()?.port();
52    let url = format!("http://127.0.0.1:{}", port);
53
54    println!("{} {}", "githops graph:".green().bold(), url.cyan().bold());
55    println!(
56        "  {}",
57        "Press Ctrl+C to stop. Changes are saved to githops.yaml immediately.".dimmed()
58    );
59
60    if open {
61        open_in_browser(&url);
62    } else {
63        println!(
64            "  {} Use {} to open in browser.",
65            "tip:".dimmed(),
66            "githops graph --open".cyan()
67        );
68    }
69    println!();
70
71    let app = Router::new()
72        .route("/", get(serve_html))
73        .route("/docs", get(serve_html))
74        .route("/docs/*path", get(serve_html))
75        .route("/assets/app.js", get(serve_js))
76        .route("/assets/app.css", get(serve_css))
77        .route("/ws", get(ws_handler))
78        .with_state(config_path);
79
80    axum::serve(listener, app).await?;
81    Ok(())
82}
83
84// ---------------------------------------------------------------------------
85// HTTP handlers
86// ---------------------------------------------------------------------------
87
88async fn serve_html() -> Html<&'static str> {
89    Html(INDEX_HTML)
90}
91
92async fn serve_js() -> Response {
93    (
94        [(
95            header::CONTENT_TYPE,
96            "application/javascript; charset=utf-8",
97        )],
98        APP_JS,
99    )
100        .into_response()
101}
102
103async fn serve_css() -> Response {
104    (
105        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
106        APP_CSS,
107    )
108        .into_response()
109}
110
111// ---------------------------------------------------------------------------
112// WebSocket: CDP-style protocol
113// ---------------------------------------------------------------------------
114
115async fn ws_handler(
116    ws: WebSocketUpgrade,
117    State(config_path): State<Arc<PathBuf>>,
118) -> Response {
119    ws.on_upgrade(move |socket| ws_loop(socket, config_path))
120}
121
122fn config_mtime(path: &Path) -> Option<SystemTime> {
123    path.metadata().ok()?.modified().ok()
124}
125
126async fn ws_loop(mut socket: WebSocket, config_path: Arc<PathBuf>) {
127    if let Ok(json) = api_state(&config_path) {
128        let event = format!(r#"{{"method":"state","params":{}}}"#, json);
129        if socket.send(Message::Text(event)).await.is_err() {
130            return;
131        }
132    }
133
134    let mut last_mtime = config_mtime(&config_path);
135    let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
136
137    loop {
138        tokio::select! {
139            _ = interval.tick() => {
140                let mtime = config_mtime(&config_path);
141                if mtime != last_mtime {
142                    last_mtime = mtime;
143                    if let Ok(json) = api_state(&config_path) {
144                        let event = format!(r#"{{"method":"state","params":{}}}"#, json);
145                        if socket.send(Message::Text(event)).await.is_err() {
146                            return;
147                        }
148                    }
149                }
150            }
151            msg = socket.recv() => {
152                match msg {
153                    Some(Ok(Message::Text(text))) => {
154                        let (response, push_state) = dispatch_ws(&text, &config_path);
155                        if socket.send(Message::Text(response)).await.is_err() {
156                            return;
157                        }
158                        if push_state {
159                            last_mtime = config_mtime(&config_path);
160                            if let Ok(json) = api_state(&config_path) {
161                                let event = format!(r#"{{"method":"state","params":{}}}"#, json);
162                                if socket.send(Message::Text(event)).await.is_err() {
163                                    return;
164                                }
165                            }
166                        }
167                    }
168                    Some(Ok(Message::Close(_))) | None => return,
169                    _ => {}
170                }
171            }
172        }
173    }
174}
175
176fn dispatch_ws(text: &str, config_path: &Path) -> (String, bool) {
177    #[derive(serde::Deserialize)]
178    struct WsReq {
179        id: u64,
180        method: String,
181        #[serde(default)]
182        params: serde_json::Value,
183    }
184
185    let req = match serde_json::from_str::<WsReq>(text) {
186        Ok(r) => r,
187        Err(e) => {
188            return (
189                format!(r#"{{"id":0,"error":{{"message":"parse error: {}"}}}}"#, e),
190                false,
191            );
192        }
193    };
194
195    let id = req.id;
196    match handle_ws_request(&req.method, req.params, config_path) {
197        Ok(result) => (
198            serde_json::json!({"id": id, "result": result}).to_string(),
199            true,
200        ),
201        Err(e) => (
202            serde_json::json!({"id": id, "error": {"message": e.to_string()}}).to_string(),
203            false,
204        ),
205    }
206}
207
208fn handle_ws_request(
209    method: &str,
210    params: serde_json::Value,
211    config_path: &Path,
212) -> Result<serde_json::Value> {
213    match method {
214        "hook.update" | "hook.remove" | "command.update" | "definition.update"
215        | "definition.delete" => {
216            let action = match method {
217                "hook.update" => "update",
218                "hook.remove" => "remove",
219                "command.update" => "update-command",
220                "definition.update" => "update-definition",
221                "definition.delete" => "delete-definition",
222                _ => unreachable!(),
223            };
224            let mut obj = match params {
225                serde_json::Value::Object(m) => m,
226                _ => serde_json::Map::new(),
227            };
228            obj.insert(
229                "action".into(),
230                serde_json::Value::String(action.to_string()),
231            );
232            let body = serde_json::to_vec(&serde_json::Value::Object(obj))?;
233            api_update(&body, config_path)?;
234            Ok(serde_json::json!({ "ok": true }))
235        }
236        "sync" => {
237            let msg = api_sync(config_path)?;
238            Ok(serde_json::json!({ "ok": true, "message": msg }))
239        }
240        "cache.clear" => {
241            let config = if config_path.exists() {
242                Config::load(config_path)?
243            } else {
244                Config::default()
245            };
246            let cache_dir = config.cache.cache_dir();
247            let mut cleared = 0u32;
248            if cache_dir.exists() {
249                for entry in std::fs::read_dir(&cache_dir)?.flatten() {
250                    if entry.path().extension().map(|x| x == "ok").unwrap_or(false) {
251                        std::fs::remove_file(entry.path())?;
252                        cleared += 1;
253                    }
254                }
255            }
256            Ok(serde_json::json!({ "ok": true, "cleared": cleared }))
257        }
258        "cache.update" => {
259            let mut config = if config_path.exists() {
260                Config::load(config_path)?
261            } else {
262                Config::default()
263            };
264            if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
265                config.cache.enabled = enabled;
266            }
267            if let Some(dir_val) = params.get("dir") {
268                config.cache.dir = dir_val
269                    .as_str()
270                    .filter(|s| !s.is_empty() && *s != ".githops/cache")
271                    .map(|s| s.to_string());
272            }
273            // If nothing meaningful is set, reset to default (omitted from yaml)
274            if !config.cache.enabled && config.cache.dir.is_none() {
275                config.cache = GlobalCache::default();
276            }
277            config.save(config_path)?;
278            Ok(serde_json::json!({ "ok": true }))
279        }
280        other => anyhow::bail!("unknown method: {other}"),
281    }
282}
283
284// ---------------------------------------------------------------------------
285// API logic
286// ---------------------------------------------------------------------------
287
288fn api_state(config_path: &Path) -> Result<String> {
289    let config = if config_path.exists() {
290        Config::load(config_path)?
291    } else {
292        Config::default()
293    };
294    let hooks_dir_path = hooks_dir().unwrap_or_else(|_| PathBuf::from(".git/hooks"));
295
296    // ── Cache status ──────────────────────────────────────────────────────────
297    let cache_dir = config.cache.cache_dir();
298    let cache_dir_str = config.cache.dir.as_deref().unwrap_or(".githops/cache").to_string();
299    let cache_entries: Vec<serde_json::Value> = if cache_dir.exists() {
300        std::fs::read_dir(&cache_dir)
301            .into_iter()
302            .flatten()
303            .flatten()
304            .filter(|e| e.path().extension().map(|x| x == "ok").unwrap_or(false))
305            .map(|e| {
306                let key = e
307                    .path()
308                    .file_stem()
309                    .unwrap_or_default()
310                    .to_string_lossy()
311                    .to_string();
312                let age_ms = e
313                    .metadata()
314                    .ok()
315                    .and_then(|m| m.modified().ok())
316                    .and_then(|t| SystemTime::now().duration_since(t).ok())
317                    .map(|d| d.as_millis() as u64)
318                    .unwrap_or(0);
319                serde_json::json!({ "key": key, "ageMs": age_ms })
320            })
321            .collect()
322    } else {
323        vec![]
324    };
325
326    let hook_states: Vec<serde_json::Value> = ALL_HOOKS
327        .iter()
328        .map(|info| {
329            let installed = hooks_dir_path.join(info.name).exists();
330            let cfg = config.hooks.get(info.name);
331            let commands: Vec<serde_json::Value> = cfg
332                .map(|c| {
333                    c.commands
334                        .iter()
335                        .map(|entry| match entry {
336                            CommandEntry::Ref(r) => {
337                                let (def_name, def_run) = config
338                                    .definitions
339                                    .get(&r.r#ref)
340                                    .and_then(|d| match d {
341                                        DefinitionEntry::Single(cmd) => {
342                                            Some((cmd.name.clone(), cmd.run.clone()))
343                                        }
344                                        _ => None,
345                                    })
346                                    .unwrap_or_else(|| (r.r#ref.clone(), String::new()));
347                                serde_json::json!({
348                                    "isRef":        true,
349                                    "refName":      r.r#ref,
350                                    "name":         r.name.as_deref().unwrap_or(&def_name),
351                                    "nameOverride": r.name.as_deref().unwrap_or(""),
352                                    "run":          def_run,
353                                    "refArgs":      r.args.as_deref().unwrap_or(""),
354                                    "depends": [],
355                                    "env":     {},
356                                    "test":    false,
357                                })
358                            }
359                            CommandEntry::Inline(cmd) => serde_json::json!({
360                                "isRef":   false,
361                                "refName": "",
362                                "name":    cmd.name,
363                                "run":     cmd.run,
364                                "depends": cmd.depends,
365                                "env":     cmd.env,
366                                "test":    cmd.test,
367                                "cache":   cmd.cache.as_ref().map(|c| serde_json::json!({
368                                    "inputs": c.inputs,
369                                    "key":    c.key,
370                                })),
371                            }),
372                        })
373                        .collect()
374                })
375                .unwrap_or_default();
376
377            serde_json::json!({
378                "name":        info.name,
379                "description": info.description,
380                "category":    info.category.label(),
381                "configured":  cfg.is_some(),
382                "installed":   installed,
383                "enabled":     cfg.map(|c| c.enabled).unwrap_or(false),
384                "parallel":    cfg.map(|c| c.parallel).unwrap_or(false),
385                "commands":    commands,
386            })
387        })
388        .collect();
389
390    let mut seen: HashSet<String> = HashSet::new();
391    let mut unique_commands: Vec<serde_json::Value> = Vec::new();
392    for hook_info in ALL_HOOKS {
393        if let Some(cfg) = config.hooks.get(hook_info.name) {
394            for entry in &cfg.commands {
395                if let CommandEntry::Inline(cmd) = entry {
396                    if seen.insert(cmd.name.clone()) {
397                        let used_in: Vec<&str> = ALL_HOOKS
398                            .iter()
399                            .filter(|h| {
400                                config
401                                    .hooks
402                                    .get(h.name)
403                                    .map(|c| {
404                                        c.commands.iter().any(|e| {
405                                            if let CommandEntry::Inline(ic) = e {
406                                                ic.name == cmd.name
407                                            } else {
408                                                false
409                                            }
410                                        })
411                                    })
412                                    .unwrap_or(false)
413                            })
414                            .map(|h| h.name)
415                            .collect();
416                        unique_commands.push(serde_json::json!({
417                            "name":   cmd.name,
418                            "run":    cmd.run,
419                            "test":   cmd.test,
420                            "usedIn": used_in,
421                        }));
422                    }
423                }
424            }
425        }
426    }
427
428    let definitions: Vec<serde_json::Value> = config
429        .definitions
430        .iter()
431        .map(|(name, def)| {
432            let (def_type, cmds) = match def {
433                DefinitionEntry::Single(cmd) => (
434                    "single",
435                    vec![serde_json::json!({
436                        "name": cmd.name, "run": cmd.run,
437                        "depends": cmd.depends, "env": cmd.env, "test": cmd.test,
438                    })],
439                ),
440                DefinitionEntry::List(cmds) => (
441                    "list",
442                    cmds.iter()
443                        .map(|cmd| {
444                            serde_json::json!({
445                                "name": cmd.name, "run": cmd.run,
446                                "depends": cmd.depends, "env": cmd.env, "test": cmd.test,
447                            })
448                        })
449                        .collect(),
450                ),
451            };
452            serde_json::json!({ "name": name, "type": def_type, "commands": cmds })
453        })
454        .collect();
455
456    Ok(serde_json::to_string(&serde_json::json!({
457        "hooks":        hook_states,
458        "commands":     unique_commands,
459        "definitions":  definitions,
460        "configExists": config_path.exists(),
461        "cacheStatus": {
462            "enabled": config.cache.enabled,
463            "dir":     cache_dir_str,
464            "entries": cache_entries,
465        },
466    }))?)
467}
468
469fn null_as_default<'de, D, T>(d: D) -> Result<T, D::Error>
470where
471    D: serde::Deserializer<'de>,
472    T: Default + serde::Deserialize<'de>,
473{
474    use serde::Deserialize;
475    Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
476}
477
478#[derive(serde::Deserialize)]
479struct UpdateRequest {
480    action: String,
481    #[serde(default, deserialize_with = "null_as_default")]
482    hook: String,
483    #[serde(default)]
484    enabled: bool,
485    #[serde(default)]
486    parallel: bool,
487    #[serde(default)]
488    commands: Vec<CommandDto>,
489    #[serde(default, rename = "oldName", deserialize_with = "null_as_default")]
490    old_name: String,
491    #[serde(default, deserialize_with = "null_as_default")]
492    name: String,
493    #[serde(default, deserialize_with = "null_as_default")]
494    run: String,
495    #[serde(default, rename = "defType", deserialize_with = "null_as_default")]
496    def_type: String,
497}
498
499#[derive(serde::Deserialize, Default)]
500struct CommandCacheDto {
501    #[serde(default)]
502    inputs: Vec<String>,
503    #[serde(default)]
504    key: Vec<String>,
505}
506
507#[derive(serde::Deserialize)]
508struct CommandDto {
509    #[serde(default, deserialize_with = "null_as_default")]
510    name: String,
511    #[serde(default, deserialize_with = "null_as_default")]
512    run: String,
513    #[serde(default)]
514    depends: Vec<String>,
515    #[serde(default)]
516    env: BTreeMap<String, String>,
517    #[serde(default)]
518    test: bool,
519    #[serde(default, rename = "isRef")]
520    is_ref: bool,
521    #[serde(default, rename = "refName", deserialize_with = "null_as_default")]
522    ref_name: String,
523    /// Extra arguments appended to the definition's run command (refs only).
524    #[serde(default, rename = "refArgs", deserialize_with = "null_as_default")]
525    ref_args: String,
526    /// Explicit name override for this ref use-site (empty = use definition name).
527    #[serde(default, rename = "nameOverride", deserialize_with = "null_as_default")]
528    name_override: String,
529    #[serde(default)]
530    cache: Option<CommandCacheDto>,
531}
532
533impl CommandDto {
534    fn into_cache(c: CommandCacheDto) -> CommandCache {
535        CommandCache { inputs: c.inputs, key: c.key }
536    }
537
538    fn into_command(self) -> Command {
539        Command {
540            name: self.name,
541            run: self.run,
542            depends: self.depends,
543            env: self.env,
544            test: self.test,
545            cache: self.cache.map(Self::into_cache),
546        }
547    }
548    fn into_entry(self) -> CommandEntry {
549        if self.is_ref {
550            CommandEntry::Ref(RefEntry {
551                r#ref: self.ref_name,
552                args: if self.ref_args.is_empty() { None } else { Some(self.ref_args) },
553                name: if self.name_override.is_empty() { None } else { Some(self.name_override) },
554            })
555        } else {
556            let cache = self.cache.map(Self::into_cache);
557            CommandEntry::Inline(Command {
558                name: self.name,
559                run: self.run,
560                depends: self.depends,
561                env: self.env,
562                test: self.test,
563                cache,
564            })
565        }
566    }
567}
568
569fn api_update(body: &[u8], config_path: &Path) -> Result<()> {
570    let req: UpdateRequest = serde_json::from_slice(body)?;
571    let mut config = if config_path.exists() {
572        Config::load(config_path)?
573    } else {
574        Config::default()
575    };
576
577    match req.action.as_str() {
578        "update" => {
579            let commands: Vec<CommandEntry> =
580                req.commands.into_iter().map(|c| c.into_entry()).collect();
581            let temp_cfg = HookConfig {
582                enabled: req.enabled,
583                parallel: req.parallel,
584                commands: commands.clone(),
585            };
586            let resolved = temp_cfg.resolved_commands(&config.definitions);
587            githops_core::config::validate_depends_pub(&resolved)?;
588            config.hooks.set(
589                &req.hook,
590                HookConfig { enabled: req.enabled, parallel: req.parallel, commands },
591            );
592        }
593        "remove" => {
594            config.hooks.remove(&req.hook);
595        }
596        "update-command" => {
597            if req.old_name.is_empty() {
598                anyhow::bail!("oldName is required for update-command");
599            }
600            if req.name.is_empty() {
601                anyhow::bail!("name is required for update-command");
602            }
603            update_command_in_all_hooks(&req.old_name, &req.name, &req.run, &mut config);
604        }
605        "update-definition" => {
606            let def_name = req.name.trim().to_string();
607            let old_name = req.old_name.trim().to_string();
608            if def_name.is_empty() {
609                anyhow::bail!("Definition name cannot be empty");
610            }
611            let entry = if req.def_type == "list" {
612                let cmds: Vec<Command> =
613                    req.commands.into_iter().map(|c| c.into_command()).collect();
614                DefinitionEntry::List(cmds)
615            } else {
616                let cmd = req
617                    .commands
618                    .into_iter()
619                    .next()
620                    .map(|c| c.into_command())
621                    .unwrap_or_else(|| Command {
622                        name: def_name.clone(),
623                        run: req.run,
624                        depends: vec![],
625                        env: BTreeMap::new(),
626                        test: false,
627                        cache: None,
628                    });
629                DefinitionEntry::Single(cmd)
630            };
631            if !old_name.is_empty() && old_name != def_name {
632                config.definitions.remove(&old_name);
633                update_def_ref_in_all_hooks(&old_name, &def_name, &mut config);
634            }
635            config.definitions.insert(def_name, entry);
636        }
637        "delete-definition" => {
638            let def_name = req.name.trim().to_string();
639            config.definitions.remove(&def_name);
640            remove_def_refs_from_hooks(&def_name, &mut config);
641        }
642        other => anyhow::bail!("Unknown action: {other}"),
643    }
644
645    config.save(config_path)?;
646    Ok(())
647}
648
649fn api_sync(config_path: &Path) -> Result<String> {
650    let config = if config_path.exists() {
651        Config::load(config_path)?
652    } else {
653        anyhow::bail!("No githops.yaml found. Run `githops init` first.");
654    };
655    let dir = hooks_dir()?;
656    let (installed, skipped) = githops_core::sync_hooks::sync_to_hooks(&config, &dir, false)?;
657    Ok(format!(
658        "Synced {} hook(s){}",
659        installed,
660        if skipped > 0 {
661            format!(" ({} skipped)", skipped)
662        } else {
663            String::new()
664        }
665    ))
666}
667
668// ---------------------------------------------------------------------------
669// Config mutation helpers
670// ---------------------------------------------------------------------------
671
672fn update_command_in_all_hooks(
673    old_name: &str,
674    new_name: &str,
675    new_run: &str,
676    config: &mut Config,
677) {
678    let mut updates: Vec<(&'static str, HookConfig)> = Vec::new();
679    for hook_info in ALL_HOOKS {
680        let hook_cfg = match config.hooks.get(hook_info.name) {
681            Some(cfg) => cfg.clone(),
682            None => continue,
683        };
684        let mut changed = false;
685        let mut new_commands = hook_cfg.commands.clone();
686        for entry in &mut new_commands {
687            if let CommandEntry::Inline(cmd) = entry {
688                if cmd.name == old_name {
689                    cmd.name = new_name.to_string();
690                    if !new_run.is_empty() {
691                        cmd.run = new_run.to_string();
692                    }
693                    changed = true;
694                }
695                for dep in &mut cmd.depends {
696                    if dep == old_name {
697                        *dep = new_name.to_string();
698                        changed = true;
699                    }
700                }
701            }
702        }
703        if changed {
704            updates.push((
705                hook_info.name,
706                HookConfig {
707                    enabled: hook_cfg.enabled,
708                    parallel: hook_cfg.parallel,
709                    commands: new_commands,
710                },
711            ));
712        }
713    }
714    for (name, cfg) in updates {
715        config.hooks.set(name, cfg);
716    }
717}
718
719fn update_def_ref_in_all_hooks(old_name: &str, new_name: &str, config: &mut Config) {
720    let mut updates: Vec<(&'static str, HookConfig)> = Vec::new();
721    for hook_info in ALL_HOOKS {
722        let hook_cfg = match config.hooks.get(hook_info.name) {
723            Some(cfg) => cfg.clone(),
724            None => continue,
725        };
726        let mut changed = false;
727        let mut new_commands = hook_cfg.commands.clone();
728        for entry in &mut new_commands {
729            if let CommandEntry::Ref(r) = entry {
730                if r.r#ref == old_name {
731                    r.r#ref = new_name.to_string();
732                    changed = true;
733                }
734            }
735        }
736        if changed {
737            updates.push((
738                hook_info.name,
739                HookConfig {
740                    enabled: hook_cfg.enabled,
741                    parallel: hook_cfg.parallel,
742                    commands: new_commands,
743                },
744            ));
745        }
746    }
747    for (name, cfg) in updates {
748        config.hooks.set(name, cfg);
749    }
750}
751
752fn remove_def_refs_from_hooks(def_name: &str, config: &mut Config) {
753    let mut updates: Vec<(&'static str, HookConfig)> = Vec::new();
754    for hook_info in ALL_HOOKS {
755        let hook_cfg = match config.hooks.get(hook_info.name) {
756            Some(cfg) => cfg.clone(),
757            None => continue,
758        };
759        let new_commands: Vec<_> = hook_cfg
760            .commands
761            .iter()
762            .filter(|e| {
763                if let CommandEntry::Ref(r) = e {
764                    r.r#ref != def_name
765                } else {
766                    true
767                }
768            })
769            .cloned()
770            .collect();
771        if new_commands.len() != hook_cfg.commands.len() {
772            updates.push((
773                hook_info.name,
774                HookConfig {
775                    enabled: hook_cfg.enabled,
776                    parallel: hook_cfg.parallel,
777                    commands: new_commands,
778                },
779            ));
780        }
781    }
782    for (name, cfg) in updates {
783        config.hooks.set(name, cfg);
784    }
785}
786
787// ---------------------------------------------------------------------------
788// Helpers
789// ---------------------------------------------------------------------------
790
791fn open_in_browser(url: &str) {
792    #[cfg(target_os = "macos")]
793    let _ = std::process::Command::new("open").arg(url).spawn();
794    #[cfg(target_os = "linux")]
795    let _ = std::process::Command::new("xdg-open").arg(url).spawn();
796    #[cfg(target_os = "windows")]
797    let _ = std::process::Command::new("cmd")
798        .args(["/c", "start", "", url])
799        .spawn();
800}