Skip to main content

pebble_cms/cli/
registry.rs

1use crate::config::Config;
2use crate::global::{GlobalConfig, PebbleHome, Registry, RegistrySite, SiteStatus};
3use anyhow::{bail, Context, Result};
4use std::fs::{self, File};
5use std::process::{Command, Stdio};
6
7pub async fn run(command: super::RegistryCommand) -> Result<()> {
8    let home = PebbleHome::init()?;
9    let global_config = GlobalConfig::load(&home.config_path)?;
10    let mut registry = Registry::load(&home.registry_path)?;
11
12    registry.cleanup_dead_processes();
13
14    match command {
15        super::RegistryCommand::Init { name, title } => {
16            init_site(&home, &global_config, &mut registry, &name, title)?;
17            registry.save(&home.registry_path)?;
18        }
19        super::RegistryCommand::List => {
20            list_sites(&registry);
21        }
22        super::RegistryCommand::Serve { name, port } => {
23            serve_site(&home, &global_config, &mut registry, &name, port, false).await?;
24            registry.save(&home.registry_path)?;
25        }
26        super::RegistryCommand::Deploy { name, port } => {
27            serve_site(&home, &global_config, &mut registry, &name, port, true).await?;
28            registry.save(&home.registry_path)?;
29        }
30        super::RegistryCommand::Stop { name } => {
31            stop_site(&mut registry, &name)?;
32            registry.save(&home.registry_path)?;
33        }
34        super::RegistryCommand::StopAll => {
35            stop_all_sites(&mut registry)?;
36            registry.save(&home.registry_path)?;
37        }
38        super::RegistryCommand::Remove { name, force } => {
39            remove_site(&home, &mut registry, &name, force)?;
40            registry.save(&home.registry_path)?;
41        }
42        super::RegistryCommand::Status { name } => {
43            show_status(&registry, &name)?;
44        }
45        super::RegistryCommand::Path { name } => {
46            show_path(&home, &registry, name)?;
47        }
48        super::RegistryCommand::Rerender { name } => {
49            rerender_site(&home, &registry, &name)?;
50        }
51        super::RegistryCommand::Config { name, command } => {
52            site_config(&home, &global_config, &mut registry, &name, command).await?;
53            registry.save(&home.registry_path)?;
54        }
55    }
56
57    Ok(())
58}
59
60fn init_site(
61    home: &PebbleHome,
62    global_config: &GlobalConfig,
63    registry: &mut Registry,
64    name: &str,
65    title: Option<String>,
66) -> Result<()> {
67    if !is_valid_site_name(name) {
68        bail!("Invalid site name: must be lowercase alphanumeric with hyphens only");
69    }
70
71    let site_path = home.site_path(name);
72    if site_path.exists() {
73        bail!("Site directory already exists: {}", site_path.display());
74    }
75
76    if registry.get_site(name).is_some() {
77        bail!("Site '{}' already registered", name);
78    }
79
80    fs::create_dir_all(&site_path)?;
81
82    let site_title = title.unwrap_or_else(|| name.replace('-', " ").to_string());
83    let config_path = site_path.join("pebble.toml");
84    let config_content = create_site_config_toml(name, &site_title, global_config);
85    fs::write(&config_path, config_content)?;
86
87    let db_dir = site_path.join("data");
88    fs::create_dir_all(&db_dir)?;
89
90    let db_path = db_dir.join("pebble.db");
91    let db = crate::Database::open(db_path.to_str().ok_or_else(|| anyhow::anyhow!("Database path contains invalid UTF-8"))?)?;
92    crate::Database::migrate(&db)?;
93
94    let media_dir = site_path.join("data").join("media");
95    fs::create_dir_all(&media_dir)?;
96
97    let site = RegistrySite {
98        name: name.to_string(),
99        title: site_title.clone(),
100        description: String::new(),
101        created_at: chrono::Utc::now().to_rfc3339(),
102        status: SiteStatus::Stopped,
103        port: None,
104        pid: None,
105        last_started: None,
106    };
107    registry.add_site(site)?;
108
109    println!("Created site '{}' at {}", name, site_path.display());
110    println!("Run: pebble registry serve {} to start it", name);
111
112    Ok(())
113}
114
115fn create_site_config_toml(name: &str, title: &str, global_config: &GlobalConfig) -> String {
116    format!(
117        r#"[site]
118title = "{}"
119description = "A Pebble site: {}"
120url = "http://localhost:{}"
121language = "{}"
122
123[server]
124host = "127.0.0.1"
125port = {}
126
127[database]
128path = "./data/pebble.db"
129
130[content]
131posts_per_page = {}
132excerpt_length = {}
133auto_excerpt = true
134
135[media]
136upload_dir = "./data/media"
137max_upload_size = "10MB"
138
139[theme]
140name = "{}"
141
142[auth]
143session_lifetime = "7d"
144"#,
145        title,
146        name,
147        global_config.defaults.dev_port,
148        global_config.defaults.language,
149        global_config.defaults.dev_port,
150        global_config.defaults.posts_per_page,
151        global_config.defaults.excerpt_length,
152        global_config.defaults.theme,
153    )
154}
155
156fn list_sites(registry: &Registry) {
157    let sites = registry.list_sites();
158
159    if sites.is_empty() {
160        println!("No sites registered.");
161        println!("Run: pebble registry init <name> to create one");
162        return;
163    }
164
165    println!(
166        "{:<20} {:<12} {:<8} {:<30}",
167        "NAME", "STATUS", "PORT", "TITLE"
168    );
169    println!("{}", "-".repeat(72));
170
171    for site in sites {
172        let port_str = site
173            .port
174            .map(|p| p.to_string())
175            .unwrap_or_else(|| "-".to_string());
176        let title = if site.title.len() > 28 {
177            format!("{}...", &site.title[..25])
178        } else {
179            site.title.clone()
180        };
181        println!(
182            "{:<20} {:<12} {:<8} {:<30}",
183            site.name,
184            site.status.to_string(),
185            port_str,
186            title
187        );
188    }
189}
190
191async fn serve_site(
192    home: &PebbleHome,
193    global_config: &GlobalConfig,
194    registry: &mut Registry,
195    name: &str,
196    port: Option<u16>,
197    production: bool,
198) -> Result<()> {
199    let site = registry
200        .get_site(name)
201        .context(format!("Site '{}' not found in registry", name))?;
202
203    if site.status == SiteStatus::Running {
204        println!(
205            "Site '{}' is already running on port {}",
206            name,
207            site.port.unwrap_or(0)
208        );
209        return Ok(());
210    }
211
212    let site_path = home.site_path(name);
213    if !site_path.exists() {
214        bail!("Site directory not found: {}", site_path.display());
215    }
216
217    let config_path = site_path.join("pebble.toml");
218    if !config_path.exists() {
219        bail!("Site config not found: {}", config_path.display());
220    }
221
222    let port = match port {
223        Some(p) => p,
224        None => {
225            // Read port from site config, fall back to auto-assign if not available
226            Config::load(&config_path)
227                .map(|c| c.server.port)
228                .unwrap_or_else(|_| {
229                    registry
230                        .find_available_port(
231                            global_config.registry.auto_port_range_start,
232                            global_config.registry.auto_port_range_end,
233                        )
234                        .unwrap_or(global_config.defaults.dev_port)
235                })
236        }
237    };
238
239    let exe = std::env::current_exe()?;
240    let mode = if production { "deploy" } else { "serve" };
241    let host = if production { "0.0.0.0" } else { "127.0.0.1" };
242
243    let logs_dir = site_path.join("logs");
244    fs::create_dir_all(&logs_dir)?;
245    let log_file = logs_dir.join(format!("{}.log", name));
246    let stdout_file = File::create(&log_file).context("Failed to create log file")?;
247    let stderr_file = stdout_file
248        .try_clone()
249        .context("Failed to clone log file handle")?;
250
251    let child = Command::new(&exe)
252        .args([
253            "--config",
254            config_path.to_str().ok_or_else(|| anyhow::anyhow!("Config path contains invalid UTF-8"))?,
255            mode,
256            "-H",
257            host,
258            "-p",
259            &port.to_string(),
260        ])
261        .current_dir(&site_path)
262        .stdin(Stdio::null())
263        .stdout(Stdio::from(stdout_file))
264        .stderr(Stdio::from(stderr_file))
265        .spawn()
266        .context("Failed to spawn server process")?;
267
268    let pid = child.id();
269    let status = if production {
270        SiteStatus::Deploying
271    } else {
272        SiteStatus::Running
273    };
274
275    registry.update_site_status(name, status, Some(port), Some(pid));
276
277    println!("Started '{}' ({}) on http://{}:{}", name, mode, host, port);
278    println!("PID: {}", pid);
279    println!("Logs: {}", log_file.display());
280
281    Ok(())
282}
283
284fn stop_site(registry: &mut Registry, name: &str) -> Result<()> {
285    let site = registry
286        .get_site(name)
287        .context(format!("Site '{}' not found in registry", name))?;
288
289    if site.status == SiteStatus::Stopped {
290        println!("Site '{}' is not running", name);
291        return Ok(());
292    }
293
294    if let Some(pid) = site.pid {
295        kill_process(pid)?;
296        println!("Stopped site '{}' (PID: {})", name, pid);
297    }
298
299    registry.update_site_status(name, SiteStatus::Stopped, None, None);
300    Ok(())
301}
302
303fn stop_all_sites(registry: &mut Registry) -> Result<()> {
304    let running: Vec<String> = registry
305        .running_sites()
306        .iter()
307        .map(|s| s.name.clone())
308        .collect();
309
310    if running.is_empty() {
311        println!("No sites are running");
312        return Ok(());
313    }
314
315    for name in running {
316        if let Some(site) = registry.get_site(&name) {
317            if let Some(pid) = site.pid {
318                kill_process(pid)?;
319                println!("Stopped '{}' (PID: {})", name, pid);
320            }
321        }
322        registry.update_site_status(&name, SiteStatus::Stopped, None, None);
323    }
324
325    Ok(())
326}
327
328fn remove_site(home: &PebbleHome, registry: &mut Registry, name: &str, force: bool) -> Result<()> {
329    let site = registry
330        .get_site(name)
331        .context(format!("Site '{}' not found in registry", name))?;
332
333    if site.status == SiteStatus::Running && !force {
334        bail!("Site '{}' is running. Stop it first or use --force", name);
335    }
336
337    if site.status == SiteStatus::Running {
338        if let Some(pid) = site.pid {
339            let _ = kill_process(pid);
340        }
341    }
342
343    let site_path = home.site_path(name);
344    if site_path.exists() {
345        fs::remove_dir_all(&site_path)
346            .with_context(|| format!("Failed to remove site directory: {}", site_path.display()))?;
347    }
348
349    registry.remove_site(name);
350    println!("Removed site '{}'", name);
351
352    Ok(())
353}
354
355fn show_status(registry: &Registry, name: &str) -> Result<()> {
356    let site = registry
357        .get_site(name)
358        .context(format!("Site '{}' not found in registry", name))?;
359
360    println!("Name:        {}", site.name);
361    println!("Title:       {}", site.title);
362    println!("Status:      {}", site.status);
363    if let Some(port) = site.port {
364        println!("Port:        {}", port);
365        println!("URL:         http://localhost:{}", port);
366    }
367    if let Some(pid) = site.pid {
368        println!("PID:         {}", pid);
369    }
370    println!("Created:     {}", site.created_at);
371    if let Some(ref started) = site.last_started {
372        println!("Last Start:  {}", started);
373    }
374
375    Ok(())
376}
377
378fn show_path(home: &PebbleHome, registry: &Registry, name: Option<String>) -> Result<()> {
379    match name {
380        Some(n) => {
381            registry
382                .get_site(&n)
383                .context(format!("Site '{}' not found in registry", n))?;
384            println!("{}", home.site_path(&n).display());
385        }
386        None => {
387            println!("{}", home.registry_dir.display());
388        }
389    }
390    Ok(())
391}
392
393fn rerender_site(home: &PebbleHome, registry: &Registry, name: &str) -> Result<()> {
394    registry
395        .get_site(name)
396        .context(format!("Site '{}' not found in registry", name))?;
397
398    let site_path = home.site_path(name);
399    let config_path = site_path.join("pebble.toml");
400
401    if !config_path.exists() {
402        bail!("Config file not found: {}", config_path.display());
403    }
404
405    let config = Config::load(&config_path)?;
406    let db = crate::Database::open(&config.database.path)?;
407
408    println!("Re-rendering all content for '{}'...", name);
409    let count = crate::services::content::rerender_all_content(&db)?;
410    println!("Successfully re-rendered {} content items.", count);
411
412    Ok(())
413}
414
415fn is_valid_site_name(name: &str) -> bool {
416    !name.is_empty()
417        && name.len() <= 64
418        && name
419            .chars()
420            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
421        && !name.starts_with('-')
422        && !name.ends_with('-')
423}
424
425#[cfg(unix)]
426fn kill_process(pid: u32) -> Result<()> {
427    unsafe {
428        if libc::kill(pid as i32, libc::SIGTERM) != 0 {
429            bail!("Failed to kill process {}", pid);
430        }
431    }
432    Ok(())
433}
434
435#[cfg(windows)]
436fn kill_process(pid: u32) -> Result<()> {
437    Command::new("taskkill")
438        .args(["/PID", &pid.to_string(), "/F"])
439        .output()
440        .context("Failed to kill process")?;
441    Ok(())
442}
443
444async fn site_config(
445    home: &PebbleHome,
446    global_config: &GlobalConfig,
447    registry: &mut Registry,
448    name: &str,
449    command: Option<super::SiteConfigCommand>,
450) -> Result<()> {
451    let site = registry
452        .get_site(name)
453        .context(format!("Site '{}' not found in registry", name))?;
454
455    let was_running = site.status == SiteStatus::Running;
456
457    let site_path = home.site_path(name);
458    let config_path = site_path.join("pebble.toml");
459
460    if !config_path.exists() {
461        bail!("Config file not found: {}", config_path.display());
462    }
463
464    let needs_restart = match &command {
465        None => false,
466        Some(super::SiteConfigCommand::Get { .. }) => false,
467        Some(super::SiteConfigCommand::Set { .. }) => true,
468        Some(super::SiteConfigCommand::Edit) => true,
469    };
470
471    match command {
472        None => {
473            // Show full config
474            show_site_config(&config_path)?;
475        }
476        Some(super::SiteConfigCommand::Get { key }) => {
477            get_site_config_value(&config_path, &key)?;
478        }
479        Some(super::SiteConfigCommand::Set { key, value }) => {
480            set_site_config_value(&config_path, &key, &value)?;
481        }
482        Some(super::SiteConfigCommand::Edit) => {
483            edit_site_config(&config_path)?;
484        }
485    }
486
487    // Restart site if it was running and config was modified
488    if needs_restart && was_running {
489        println!("Restarting site to apply changes...");
490        stop_site(registry, name)?;
491        // Use None for port to let it read from the updated config
492        serve_site(home, global_config, registry, name, None, false).await?;
493    }
494
495    Ok(())
496}
497
498fn show_site_config(config_path: &std::path::Path) -> Result<()> {
499    let config = Config::load(config_path)?;
500
501    println!("# Site");
502    println!("{:<30}  {}", "site.title", config.site.title);
503    println!("{:<30}  {}", "site.description", config.site.description);
504    println!("{:<30}  {}", "site.url", config.site.url);
505    println!("{:<30}  {}", "site.language", config.site.language);
506    println!();
507
508    println!("# Server");
509    println!("{:<30}  {}", "server.host", config.server.host);
510    println!("{:<30}  {}", "server.port", config.server.port);
511    println!();
512
513    println!("# Content");
514    println!(
515        "{:<30}  {}",
516        "content.posts_per_page", config.content.posts_per_page
517    );
518    println!(
519        "{:<30}  {}",
520        "content.excerpt_length", config.content.excerpt_length
521    );
522    println!(
523        "{:<30}  {}",
524        "content.auto_excerpt", config.content.auto_excerpt
525    );
526    println!();
527
528    println!("# Theme");
529    println!("{:<30}  {}", "theme.name", config.theme.name);
530    if let Some(ref v) = config.theme.custom.primary_color {
531        println!("{:<30}  {}", "theme.custom.primary_color", v);
532    }
533    if let Some(ref v) = config.theme.custom.accent_color {
534        println!("{:<30}  {}", "theme.custom.accent_color", v);
535    }
536    if let Some(ref v) = config.theme.custom.background_color {
537        println!("{:<30}  {}", "theme.custom.background_color", v);
538    }
539    if let Some(ref v) = config.theme.custom.text_color {
540        println!("{:<30}  {}", "theme.custom.text_color", v);
541    }
542    if let Some(ref v) = config.theme.custom.font_family {
543        println!("{:<30}  {}", "theme.custom.font_family", v);
544    }
545    println!();
546
547    println!("# Homepage");
548    println!(
549        "{:<30}  {}",
550        "homepage.show_hero", config.homepage.show_hero
551    );
552    println!(
553        "{:<30}  {}",
554        "homepage.hero_layout", config.homepage.hero_layout
555    );
556    println!(
557        "{:<30}  {}",
558        "homepage.show_posts", config.homepage.show_posts
559    );
560    println!(
561        "{:<30}  {}",
562        "homepage.posts_layout", config.homepage.posts_layout
563    );
564    println!(
565        "{:<30}  {}",
566        "homepage.show_pages", config.homepage.show_pages
567    );
568
569    Ok(())
570}
571
572fn get_site_config_value(config_path: &std::path::Path, key: &str) -> Result<()> {
573    let config = Config::load(config_path)?;
574    let value = get_config_value(&config, key)?;
575    println!("{}", value);
576    Ok(())
577}
578
579fn get_config_value(config: &Config, key: &str) -> Result<String> {
580    let parts: Vec<&str> = key.split('.').collect();
581    match parts.as_slice() {
582        // Site
583        ["site", "title"] => Ok(config.site.title.clone()),
584        ["site", "description"] => Ok(config.site.description.clone()),
585        ["site", "url"] => Ok(config.site.url.clone()),
586        ["site", "language"] => Ok(config.site.language.clone()),
587        // Server
588        ["server", "host"] => Ok(config.server.host.clone()),
589        ["server", "port"] => Ok(config.server.port.to_string()),
590        // Content
591        ["content", "posts_per_page"] => Ok(config.content.posts_per_page.to_string()),
592        ["content", "excerpt_length"] => Ok(config.content.excerpt_length.to_string()),
593        ["content", "auto_excerpt"] => Ok(config.content.auto_excerpt.to_string()),
594        // Theme
595        ["theme", "name"] => Ok(config.theme.name.clone()),
596        ["theme", "custom", "primary_color"] => Ok(config
597            .theme
598            .custom
599            .primary_color
600            .clone()
601            .unwrap_or_default()),
602        ["theme", "custom", "accent_color"] => {
603            Ok(config.theme.custom.accent_color.clone().unwrap_or_default())
604        }
605        ["theme", "custom", "background_color"] => Ok(config
606            .theme
607            .custom
608            .background_color
609            .clone()
610            .unwrap_or_default()),
611        ["theme", "custom", "text_color"] => {
612            Ok(config.theme.custom.text_color.clone().unwrap_or_default())
613        }
614        ["theme", "custom", "font_family"] => {
615            Ok(config.theme.custom.font_family.clone().unwrap_or_default())
616        }
617        // Homepage
618        ["homepage", "show_hero"] => Ok(config.homepage.show_hero.to_string()),
619        ["homepage", "hero_layout"] => Ok(config.homepage.hero_layout.clone()),
620        ["homepage", "hero_height"] => Ok(config.homepage.hero_height.clone()),
621        ["homepage", "show_posts"] => Ok(config.homepage.show_posts.to_string()),
622        ["homepage", "posts_layout"] => Ok(config.homepage.posts_layout.clone()),
623        ["homepage", "posts_columns"] => Ok(config.homepage.posts_columns.to_string()),
624        ["homepage", "show_pages"] => Ok(config.homepage.show_pages.to_string()),
625        ["homepage", "pages_layout"] => Ok(config.homepage.pages_layout.clone()),
626        // Auth
627        ["auth", "session_lifetime"] => Ok(config.auth.session_lifetime.clone()),
628        _ => bail!("Unknown config key: {}", key),
629    }
630}
631
632fn set_site_config_value(config_path: &std::path::Path, key: &str, value: &str) -> Result<()> {
633    let content = fs::read_to_string(config_path)?;
634    let mut doc = content
635        .parse::<toml_edit::DocumentMut>()
636        .context("Failed to parse config file")?;
637
638    let parts: Vec<&str> = key.split('.').collect();
639    match parts.as_slice() {
640        // Site
641        ["site", "title"] => doc["site"]["title"] = toml_edit::value(value),
642        ["site", "description"] => doc["site"]["description"] = toml_edit::value(value),
643        ["site", "url"] => doc["site"]["url"] = toml_edit::value(value),
644        ["site", "language"] => doc["site"]["language"] = toml_edit::value(value),
645        // Server
646        ["server", "host"] => doc["server"]["host"] = toml_edit::value(value),
647        ["server", "port"] => {
648            let port: i64 = value.parse().context("Invalid port number")?;
649            doc["server"]["port"] = toml_edit::value(port);
650        }
651        // Content
652        ["content", "posts_per_page"] => {
653            let n: i64 = value.parse().context("Invalid number")?;
654            doc["content"]["posts_per_page"] = toml_edit::value(n);
655        }
656        ["content", "excerpt_length"] => {
657            let n: i64 = value.parse().context("Invalid number")?;
658            doc["content"]["excerpt_length"] = toml_edit::value(n);
659        }
660        ["content", "auto_excerpt"] => {
661            let b: bool = value.parse().context("Invalid boolean (use true/false)")?;
662            doc["content"]["auto_excerpt"] = toml_edit::value(b);
663        }
664        // Theme
665        ["theme", "name"] => {
666            if !crate::config::ThemeConfig::is_valid_theme(value) {
667                bail!(
668                    "Invalid theme '{}'. Available: {}",
669                    value,
670                    crate::config::ThemeConfig::AVAILABLE_THEMES.join(", ")
671                );
672            }
673            doc["theme"]["name"] = toml_edit::value(value);
674        }
675        ["theme", "custom", field] => {
676            if !doc.contains_key("theme") {
677                doc["theme"] = toml_edit::Item::Table(toml_edit::Table::new());
678            }
679            if !doc["theme"]
680                .as_table()
681                .map_or(false, |t| t.contains_key("custom"))
682            {
683                doc["theme"]["custom"] = toml_edit::Item::Table(toml_edit::Table::new());
684            }
685            doc["theme"]["custom"][*field] = toml_edit::value(value);
686        }
687        // Homepage
688        ["homepage", "show_hero"] => {
689            let b: bool = value.parse().context("Invalid boolean (use true/false)")?;
690            ensure_homepage_table(&mut doc);
691            doc["homepage"]["show_hero"] = toml_edit::value(b);
692        }
693        ["homepage", "hero_layout"] => {
694            ensure_homepage_table(&mut doc);
695            doc["homepage"]["hero_layout"] = toml_edit::value(value);
696        }
697        ["homepage", "hero_height"] => {
698            ensure_homepage_table(&mut doc);
699            doc["homepage"]["hero_height"] = toml_edit::value(value);
700        }
701        ["homepage", "show_posts"] => {
702            let b: bool = value.parse().context("Invalid boolean (use true/false)")?;
703            ensure_homepage_table(&mut doc);
704            doc["homepage"]["show_posts"] = toml_edit::value(b);
705        }
706        ["homepage", "posts_layout"] => {
707            ensure_homepage_table(&mut doc);
708            doc["homepage"]["posts_layout"] = toml_edit::value(value);
709        }
710        ["homepage", "posts_columns"] => {
711            let n: i64 = value.parse().context("Invalid number")?;
712            ensure_homepage_table(&mut doc);
713            doc["homepage"]["posts_columns"] = toml_edit::value(n);
714        }
715        ["homepage", "show_pages"] => {
716            let b: bool = value.parse().context("Invalid boolean (use true/false)")?;
717            ensure_homepage_table(&mut doc);
718            doc["homepage"]["show_pages"] = toml_edit::value(b);
719        }
720        ["homepage", "pages_layout"] => {
721            ensure_homepage_table(&mut doc);
722            doc["homepage"]["pages_layout"] = toml_edit::value(value);
723        }
724        // Auth
725        ["auth", "session_lifetime"] => {
726            doc["auth"]["session_lifetime"] = toml_edit::value(value);
727        }
728        _ => bail!("Unknown or read-only config key: {}", key),
729    }
730
731    fs::write(config_path, doc.to_string())?;
732    println!("Set {} = {}", key, value);
733    Ok(())
734}
735
736fn ensure_homepage_table(doc: &mut toml_edit::DocumentMut) {
737    if !doc.contains_key("homepage") {
738        doc["homepage"] = toml_edit::Item::Table(toml_edit::Table::new());
739    }
740}
741
742fn edit_site_config(config_path: &std::path::Path) -> Result<()> {
743    let editor = std::env::var("EDITOR").unwrap_or_else(|_| {
744        if cfg!(target_os = "windows") {
745            "notepad".to_string()
746        } else {
747            "vi".to_string()
748        }
749    });
750
751    let status = Command::new(&editor)
752        .arg(config_path)
753        .status()
754        .with_context(|| format!("Failed to open editor: {}", editor))?;
755
756    if !status.success() {
757        bail!("Editor exited with error");
758    }
759
760    // Validate the config after editing
761    Config::load(config_path).context("Config validation failed after editing")?;
762    println!("Config saved and validated successfully");
763
764    Ok(())
765}