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(®istry);
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(®istry, &name)?;
44 }
45 super::RegistryCommand::Path { name } => {
46 show_path(&home, ®istry, name)?;
47 }
48 super::RegistryCommand::Rerender { name } => {
49 rerender_site(&home, ®istry, &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 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_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 if needs_restart && was_running {
489 println!("Restarting site to apply changes...");
490 stop_site(registry, name)?;
491 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", "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", "host"] => Ok(config.server.host.clone()),
589 ["server", "port"] => Ok(config.server.port.to_string()),
590 ["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", "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", "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", "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", "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", "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", "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", "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", "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", "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 Config::load(config_path).context("Config validation failed after editing")?;
762 println!("Config saved and validated successfully");
763
764 Ok(())
765}