Skip to main content

unifier/cli/
mod.rs

1//! CLI dispatch.
2
3mod defs;
4
5use clap::Parser;
6use defs::{
7    ChrootCommands, Cli, Commands, DaemonCommands, LogCommands, NamespaceCommands, SqlCommands,
8    TickCommands, TripleCommands, WebCommands,
9};
10
11use crate::chroot::{init_chroot, list_chroots};
12use crate::daemon::{self};
13use crate::home::UnifierHome;
14use crate::log as spanlog;
15use crate::namespace;
16use crate::postbox::{
17    ack, delete_key, get_key, list_dir, poll_cron, poll_mailbox, post_cron, put_key, send_from,
18    Message,
19};
20use crate::sqlite::{self, SqlOutcome};
21use crate::Result;
22
23#[cfg(unix)]
24use crate::daemon::{
25    response_found, response_messages, response_ok, response_uuid, response_value, Client, Request,
26};
27
28pub fn run() -> Result<()> {
29    let cli = Cli::parse();
30    let home = UnifierHome::resolve(cli.home, cli.chroot)?;
31    let ns = cli.namespace;
32
33    match cli.cmd {
34        Commands::Daemon(DaemonCommands::Start) => {
35            daemon::start(&home, false)?;
36            println!("daemon started");
37            Ok(())
38        }
39        Commands::Daemon(DaemonCommands::Run) => {
40            #[cfg(unix)]
41            {
42                daemon::run_server(home)
43            }
44            #[cfg(not(unix))]
45            {
46                Err(crate::Error::msg("hot daemon requires a Unix platform"))
47            }
48        }
49        Commands::Daemon(DaemonCommands::Stop) => {
50            daemon::stop(&home)?;
51            println!("daemon stopped");
52            Ok(())
53        }
54        Commands::Daemon(DaemonCommands::Status) => daemon::status(&home),
55        Commands::Daemon(DaemonCommands::Flush) => daemon::flush(&home),
56        Commands::Daemon(DaemonCommands::Watch) => {
57            #[cfg(unix)]
58            {
59                daemon::ensure_running(&home)?;
60                daemon::watch(&home)
61            }
62            #[cfg(not(unix))]
63            {
64                Err(crate::Error::msg("hot daemon requires a Unix platform"))
65            }
66        }
67        Commands::Daemon(DaemonCommands::Gc { dry_run }) => {
68            #[cfg(unix)]
69            {
70                let _ = home;
71                daemon::gc(dry_run)
72            }
73            #[cfg(not(unix))]
74            {
75                let _ = (home, dry_run);
76                Err(crate::Error::msg("hot daemon requires a Unix platform"))
77            }
78        }
79        Commands::Chroot(ChrootCommands::Init { name }) => {
80            init_chroot(home.global_path(), &name)?;
81            println!(
82                "chroot initialized: {}",
83                home.global_path().join("chroots").join(name).display()
84            );
85            Ok(())
86        }
87        Commands::Chroot(ChrootCommands::List) => {
88            for name in list_chroots(home.global_path())? {
89                println!("{name}");
90            }
91            Ok(())
92        }
93        Commands::Namespace(cmd) => {
94            home.ensure()?;
95            dispatch_namespace(&home, cmd, ns.as_deref())
96        }
97        Commands::Sql(cmd) => {
98            home.ensure()?;
99            dispatch_sql(&home, cmd)
100        }
101        Commands::Triple(cmd) => {
102            home.ensure()?;
103            dispatch_triple(&home, cmd)
104        }
105        Commands::Log(cmd) => {
106            home.ensure()?;
107            dispatch_log(&home, cmd)
108        }
109        Commands::Serve {
110            name,
111            file,
112            content_type,
113            ttl,
114            wrap,
115            title,
116        } => {
117            home.ensure()?;
118            dispatch_serve(
119                &home,
120                ServeOpts {
121                    name,
122                    file,
123                    content_type,
124                    ttl,
125                    wrap,
126                    title,
127                    no_daemon: cli.no_daemon,
128                },
129            )
130        }
131        Commands::Web(cmd) => {
132            home.ensure()?;
133            dispatch_web(&home, cmd, cli.no_daemon)
134        }
135        cmd => {
136            home.ensure()?;
137            if cli.no_daemon {
138                dispatch_data(&home, cmd, ns.as_deref())
139            } else {
140                #[cfg(unix)]
141                {
142                    daemon::ensure_running(&home)?;
143                    dispatch_via_daemon(&home, cmd, ns.as_deref())
144                }
145                #[cfg(not(unix))]
146                {
147                    dispatch_data(&home, cmd, ns.as_deref())
148                }
149            }
150        }
151    }
152}
153
154fn dispatch_namespace(
155    home: &UnifierHome,
156    cmd: NamespaceCommands,
157    override_ns: Option<&str>,
158) -> Result<()> {
159    match cmd {
160        NamespaceCommands::Set { name } => {
161            namespace::set(home, &name)?;
162            println!("{name}");
163            Ok(())
164        }
165        NamespaceCommands::Get => match namespace::effective(home, override_ns)? {
166            Some(name) => {
167                println!("{name}");
168                Ok(())
169            }
170            None => Err(crate::Error::msg("no namespace")),
171        },
172        NamespaceCommands::Clear => {
173            if namespace::clear(home)? {
174                Ok(())
175            } else {
176                Err(crate::Error::msg("no namespace"))
177            }
178        }
179    }
180}
181
182fn qualify(home: &UnifierHome, ns: Option<&str>, key: String) -> Result<String> {
183    namespace::qualify_key(home, ns, &key)
184}
185
186fn dispatch_sql(home: &UnifierHome, cmd: SqlCommands) -> Result<()> {
187    match cmd {
188        SqlCommands::List => {
189            for name in sqlite::list_databases(home)? {
190                println!("{name}");
191            }
192            Ok(())
193        }
194        SqlCommands::Create { name } => {
195            let path = sqlite::create_db(home, &name)?;
196            println!("{}", path.display());
197            Ok(())
198        }
199        SqlCommands::Tables { database, schema } => {
200            for table in sqlite::list_tables(home, &database)? {
201                println!("{}", table.name);
202                if schema {
203                    for col in &table.columns {
204                        if col.decl_type.is_empty() {
205                            println!("  {}", col.name);
206                        } else {
207                            println!("  {} {}", col.name, col.decl_type);
208                        }
209                    }
210                }
211            }
212            Ok(())
213        }
214        SqlCommands::Exec { database, sql } => match sqlite::exec_sql(home, &database, &sql)? {
215            SqlOutcome::Query(result) => {
216                if !result.columns.is_empty() {
217                    println!("{}", result.columns.join("\t"));
218                }
219                for row in result.rows {
220                    println!("{}", row.join("\t"));
221                }
222                Ok(())
223            }
224            SqlOutcome::Exec(result) => {
225                println!("ok {}", result.rows_affected);
226                Ok(())
227            }
228        },
229    }
230}
231
232fn dispatch_triple(home: &UnifierHome, cmd: TripleCommands) -> Result<()> {
233    match cmd {
234        TripleCommands::Add {
235            subject,
236            predicate,
237            object,
238        } => {
239            sqlite::insert_triple(home, &subject, &predicate, &object)?;
240            Ok(())
241        }
242        TripleCommands::Query {
243            subject,
244            predicate,
245            object,
246        } => {
247            let rows = sqlite::query_triples(
248                home,
249                subject.as_deref(),
250                predicate.as_deref(),
251                object.as_deref(),
252            )?;
253            for (s, p, o) in rows {
254                println!("{s}\t{p}\t{o}");
255            }
256            Ok(())
257        }
258    }
259}
260
261fn dispatch_data(home: &UnifierHome, cmd: Commands, ns: Option<&str>) -> Result<()> {
262    match cmd {
263        Commands::Put { key, value } => {
264            let key = qualify(home, ns, key)?;
265            put_key(home, &key, &value)?;
266            Ok(())
267        }
268        Commands::Get { key } => {
269            let key = qualify(home, ns, key)?;
270            match get_key(home, &key)? {
271                Some(v) => {
272                    println!("{v}");
273                    Ok(())
274                }
275                None => Err(crate::Error::msg(format!("key not found: {key}"))),
276            }
277        }
278        Commands::Del { key } => {
279            let key = qualify(home, ns, key)?;
280            if delete_key(home, &key)? {
281                Ok(())
282            } else {
283                Err(crate::Error::msg(format!("key not found: {key}")))
284            }
285        }
286        Commands::Send {
287            from,
288            recipient,
289            message,
290        } => {
291            let from = from.unwrap_or_else(|| crate::envelope::DEFAULT_SENDER.to_string());
292            let id = send_from(home, &from, &recipient, &message)?;
293            println!("{}", id.hyphenated());
294            Ok(())
295        }
296        Commands::Cron { schedule, message } => {
297            let id = post_cron(home, &schedule, &message)?;
298            println!("{}", id.hyphenated());
299            Ok(())
300        }
301        Commands::Poll {
302            recipient,
303            ack: do_ack,
304        } => {
305            let messages = poll_mailbox(home, &recipient)?;
306            print_messages(&messages);
307            if do_ack {
308                for msg in &messages {
309                    ack(home, &msg.id.hyphenated().to_string())?;
310                }
311            }
312            Ok(())
313        }
314        Commands::PollCron { ack: do_ack } => {
315            let messages = poll_cron(home)?;
316            print_messages(&messages);
317            if do_ack {
318                for msg in &messages {
319                    ack(home, &msg.id.hyphenated().to_string())?;
320                }
321            }
322            Ok(())
323        }
324        Commands::List { path } => {
325            let messages = list_dir(home, &path)?;
326            print_messages(&messages);
327            Ok(())
328        }
329        Commands::Ack { id_or_path } => {
330            if ack(home, &id_or_path)? {
331                Ok(())
332            } else {
333                Err(crate::Error::msg(format!(
334                    "message not found: {id_or_path}"
335                )))
336            }
337        }
338        Commands::Root => {
339            println!("{}", home.path().display());
340            Ok(())
341        }
342        Commands::Event { .. } | Commands::Message { .. } | Commands::Tick(_) => {
343            Err(crate::Error::msg(
344                "event, message, and tick commands require the hot daemon; omit --no-daemon",
345            ))
346        }
347        Commands::Daemon(_)
348        | Commands::Chroot(_)
349        | Commands::Namespace(_)
350        | Commands::Sql(_)
351        | Commands::Triple(_)
352        | Commands::Log(_)
353        | Commands::Serve { .. }
354        | Commands::Web(_) => {
355            unreachable!("handled in run()")
356        }
357    }
358}
359
360#[cfg(unix)]
361fn dispatch_via_daemon(home: &UnifierHome, cmd: Commands, ns: Option<&str>) -> Result<()> {
362    let mut client = Client::connect(home)?;
363    match cmd {
364        Commands::Put { key, value } => {
365            let key = qualify(home, ns, key)?;
366            response_ok(client.request(Request::Put { key, value })?)?;
367            Ok(())
368        }
369        Commands::Get { key } => {
370            let key = qualify(home, ns, key)?;
371            match response_value(client.request(Request::Get { key })?)? {
372                Some(v) => {
373                    println!("{v}");
374                    Ok(())
375                }
376                None => Err(crate::Error::msg("key not found")),
377            }
378        }
379        Commands::Del { key } => {
380            let key = qualify(home, ns, key)?;
381            response_ok(client.request(Request::Del { key })?)?;
382            Ok(())
383        }
384        Commands::Send {
385            from,
386            recipient,
387            message,
388        } => {
389            let id = response_uuid(client.request(Request::Send {
390                from,
391                recipient,
392                message,
393            })?)?;
394            println!("{}", id.hyphenated());
395            Ok(())
396        }
397        Commands::Cron { schedule, message } => {
398            let id = response_uuid(client.request(Request::Cron { schedule, message })?)?;
399            println!("{}", id.hyphenated());
400            Ok(())
401        }
402        Commands::Poll {
403            recipient,
404            ack: do_ack,
405        } => {
406            let messages = response_messages(client.request(Request::Poll { recipient })?)?;
407            print_messages(&messages);
408            if do_ack {
409                for msg in &messages {
410                    let found = response_found(client.request(Request::Ack {
411                        id_or_path: msg.id.hyphenated().to_string(),
412                    })?)?;
413                    if !found {
414                        return Err(crate::Error::msg(format!(
415                            "message not found: {}",
416                            msg.id.hyphenated()
417                        )));
418                    }
419                }
420            }
421            Ok(())
422        }
423        Commands::PollCron { ack: do_ack } => {
424            let messages = response_messages(client.request(Request::PollCron)?)?;
425            print_messages(&messages);
426            if do_ack {
427                for msg in &messages {
428                    response_found(client.request(Request::Ack {
429                        id_or_path: msg.id.hyphenated().to_string(),
430                    })?)?;
431                }
432            }
433            Ok(())
434        }
435        Commands::List { path } => {
436            let messages = response_messages(client.request(Request::List { path })?)?;
437            print_messages(&messages);
438            Ok(())
439        }
440        Commands::Ack { id_or_path } => {
441            if response_found(client.request(Request::Ack { id_or_path })?)? {
442                Ok(())
443            } else {
444                Err(crate::Error::msg("message not found"))
445            }
446        }
447        Commands::Root => {
448            println!("{}", home.path().display());
449            Ok(())
450        }
451        Commands::Event { payload, ttl } => {
452            let id = response_uuid(client.request(Request::Event { payload, ttl })?)?;
453            println!("{}", id.hyphenated());
454            Ok(())
455        }
456        Commands::Message {
457            from,
458            recipient,
459            payload,
460        } => {
461            let id = response_uuid(client.request(Request::AgentMessage {
462                from,
463                to: recipient,
464                payload,
465            })?)?;
466            println!("{}", id.hyphenated());
467            Ok(())
468        }
469        Commands::Tick(cmd) => dispatch_tick_via_daemon(home, &mut client, cmd, ns),
470        Commands::Daemon(_)
471        | Commands::Chroot(_)
472        | Commands::Namespace(_)
473        | Commands::Sql(_)
474        | Commands::Triple(_)
475        | Commands::Log(_)
476        | Commands::Serve { .. }
477        | Commands::Web(_) => {
478            unreachable!("handled in run()")
479        }
480    }
481}
482
483#[cfg(unix)]
484fn dispatch_tick_via_daemon(
485    home: &UnifierHome,
486    client: &mut Client,
487    cmd: TickCommands,
488    ns: Option<&str>,
489) -> Result<()> {
490    use crate::daemon::{response_found, response_ok, Request, Response};
491
492    // Prefer the dedicated tick control socket; fall back to the main daemon sock.
493    let mut tick_client = Client::connect_tick(home).ok();
494    let client = tick_client.as_mut().unwrap_or(client);
495
496    match cmd {
497        TickCommands::Start { label } => {
498            let resp = client.request(Request::TickStart { label })?;
499            match resp {
500                Response::Ok {
501                    tick: Some(t),
502                    phase,
503                    label,
504                    ..
505                } => {
506                    let phase = phase.unwrap_or_else(|| "start".into());
507                    if let Some(label) = label {
508                        println!("tick {t} started phase={phase} label={label}");
509                    } else {
510                        println!("tick {t} started phase={phase}");
511                    }
512                    Ok(())
513                }
514                Response::Ok {
515                    queued: Some(pos), ..
516                } => {
517                    println!("tick start queued at position {pos}");
518                    Ok(())
519                }
520                Response::Err { error } => Err(crate::Error::msg(error)),
521                _ => Err(crate::Error::msg("unexpected tick start response")),
522            }
523        }
524        TickCommands::End => {
525            let resp = client.request(Request::TickEnd)?;
526            match resp {
527                Response::Ok { tick: Some(t), .. } => {
528                    println!("tick {t} committed");
529                    Ok(())
530                }
531                Response::Err { error } => Err(crate::Error::msg(error)),
532                _ => Err(crate::Error::msg("unexpected tick end response")),
533            }
534        }
535        TickCommands::Status => {
536            let resp = client.request(Request::TickStatus)?;
537            match resp {
538                Response::Ok { value: Some(v), .. } => {
539                    println!("{v}");
540                    Ok(())
541                }
542                Response::Err { error } => Err(crate::Error::msg(error)),
543                _ => Err(crate::Error::msg("unexpected tick status response")),
544            }
545        }
546        TickCommands::Phase { phase } => {
547            let resp = client.request(Request::TickPhase { phase })?;
548            match resp {
549                Response::Ok {
550                    tick: Some(t),
551                    phase: Some(p),
552                    ..
553                } => {
554                    println!("tick {t} phase={p}");
555                    Ok(())
556                }
557                Response::Err { error } => Err(crate::Error::msg(error)),
558                _ => Err(crate::Error::msg("unexpected tick phase response")),
559            }
560        }
561        TickCommands::Lock { key } => {
562            let key = qualify(home, ns, key)?;
563            response_ok(client.request(Request::TickLock { key })?)?;
564            Ok(())
565        }
566        TickCommands::Unlock { key } => {
567            let key = qualify(home, ns, key)?;
568            if response_found(client.request(Request::TickUnlock { key })?)? {
569                Ok(())
570            } else {
571                Err(crate::Error::msg("lock not held"))
572            }
573        }
574    }
575}
576
577struct ServeOpts {
578    name: Option<String>,
579    file: Option<std::path::PathBuf>,
580    content_type: Option<String>,
581    ttl: Option<u64>,
582    wrap: bool,
583    title: String,
584    no_daemon: bool,
585}
586
587fn dispatch_serve(home: &UnifierHome, opts: ServeOpts) -> Result<()> {
588    #[cfg(not(unix))]
589    {
590        let _ = (home, opts);
591        return Err(crate::Error::msg("web serve requires a Unix platform"));
592    }
593
594    #[cfg(unix)]
595    {
596        use std::io::Read;
597        use uuid::Uuid;
598
599        if !opts.no_daemon {
600            daemon::ensure_running(home)?;
601            wait_for_http_port(home)?;
602        } else if daemon::www::base_url(home).is_none() {
603            return Err(crate::Error::msg(
604                "web server is not listening; omit --no-daemon so the daemon can start it",
605            ));
606        }
607
608        let mut raw = Vec::new();
609        if let Some(path) = opts.file {
610            raw = std::fs::read(&path)?;
611        } else {
612            std::io::stdin().read_to_end(&mut raw)?;
613        }
614
615        let body = if opts.wrap {
616            let text = String::from_utf8_lossy(&raw);
617            daemon::www::wrap_html(&opts.title, &text).into_bytes()
618        } else {
619            raw
620        };
621
622        let name = opts
623            .name
624            .unwrap_or_else(|| Uuid::new_v4().hyphenated().to_string());
625        daemon::www::publish(home, &name, &body, opts.content_type.as_deref(), opts.ttl)?;
626        let url = daemon::www::entry_url(home, &name)?;
627        println!("{url}");
628        Ok(())
629    }
630}
631
632fn dispatch_web(home: &UnifierHome, cmd: WebCommands, no_daemon: bool) -> Result<()> {
633    #[cfg(not(unix))]
634    {
635        let _ = (home, cmd, no_daemon);
636        return Err(crate::Error::msg("web commands require a Unix platform"));
637    }
638
639    #[cfg(unix)]
640    {
641        if !no_daemon {
642            daemon::ensure_running(home)?;
643            wait_for_http_port(home)?;
644        }
645
646        match cmd {
647            WebCommands::List => {
648                if !no_daemon {
649                    let mut client = Client::connect(home)?;
650                    match client.request(Request::WebList)? {
651                        crate::daemon::Response::Ok { value: Some(v), .. } => {
652                            if !v.is_empty() {
653                                println!("{v}");
654                            }
655                            Ok(())
656                        }
657                        crate::daemon::Response::Ok { .. } => Ok(()),
658                        crate::daemon::Response::Err { error } => Err(crate::Error::msg(error)),
659                    }
660                } else {
661                    for e in daemon::www::list(home)? {
662                        let url = daemon::www::entry_url(home, &e.name).unwrap_or_default();
663                        println!("{}\t{}\t{}\t{}", e.name, e.content_type, e.bytes, url);
664                    }
665                    Ok(())
666                }
667            }
668            WebCommands::Url { name } => {
669                let url = daemon::www::entry_url(home, &name)?;
670                if daemon::www::load_meta(home, &name)?.is_none() {
671                    return Err(crate::Error::msg(format!("web file not found: {name}")));
672                }
673                println!("{url}");
674                Ok(())
675            }
676            WebCommands::KeyUrl { key } => {
677                let url = daemon::www::key_url(home, &key)?;
678                println!("{url}");
679                Ok(())
680            }
681            WebCommands::Rm { name } => {
682                let found = if !no_daemon {
683                    let mut client = Client::connect(home)?;
684                    response_found(client.request(Request::WebRm { name: name.clone() })?)?
685                } else {
686                    daemon::www::remove(home, &name)?
687                };
688                if found {
689                    Ok(())
690                } else {
691                    Err(crate::Error::msg(format!("web file not found: {name}")))
692                }
693            }
694            WebCommands::Status => {
695                let url = if !no_daemon {
696                    let mut client = Client::connect(home)?;
697                    response_value(client.request(Request::WebStatus)?)?
698                        .ok_or_else(|| crate::Error::msg("web server is not listening"))?
699                } else {
700                    daemon::www::base_url(home)
701                        .ok_or_else(|| crate::Error::msg("web server is not listening"))?
702                };
703                println!("{url}");
704                Ok(())
705            }
706        }
707    }
708}
709
710#[cfg(unix)]
711fn wait_for_http_port(home: &UnifierHome) -> Result<()> {
712    for _ in 0..100 {
713        if daemon::www::base_url(home).is_some() {
714            return Ok(());
715        }
716        std::thread::sleep(std::time::Duration::from_millis(50));
717    }
718    Err(crate::Error::msg("web server failed to start"))
719}
720
721fn dispatch_log(home: &UnifierHome, cmd: LogCommands) -> Result<()> {
722    match cmd {
723        LogCommands::Start {
724            name,
725            parent,
726            fields,
727        } => {
728            let parent_id = parent.as_deref().map(parse_uuid).transpose()?;
729            let fields = spanlog::parse_fields(&fields)?;
730            let id = spanlog::span_start(home, &name, parent_id, fields)?;
731            println!("{}", id.hyphenated());
732            Ok(())
733        }
734        LogCommands::End { id } => {
735            let id = parse_uuid(&id)?;
736            spanlog::span_end(home, id)?;
737            println!("span {id} ended");
738            Ok(())
739        }
740        LogCommands::Event {
741            span,
742            message,
743            fields,
744        } => {
745            let span_id = parse_uuid(&span)?;
746            let fields = spanlog::parse_fields(&fields)?;
747            spanlog::log_event(home, span_id, &message, fields)?;
748            Ok(())
749        }
750        LogCommands::Field { span, fields } => {
751            let span_id = parse_uuid(&span)?;
752            let fields = spanlog::parse_fields(&fields)?;
753            spanlog::span_set_fields(home, span_id, fields)?;
754            Ok(())
755        }
756        LogCommands::Tree { span } => {
757            let root_id = span.as_deref().map(parse_uuid).transpose()?;
758            let spans = spanlog::load_all_spans(home)?;
759            let tree = spanlog::render_tree(&spans, root_id);
760            print!("{tree}");
761            Ok(())
762        }
763        LogCommands::List => {
764            let spans = spanlog::load_all_spans(home)?;
765            for span in &spans {
766                let status = if span.ended_at.is_some() {
767                    "ended"
768                } else {
769                    "open"
770                };
771                println!("{}\t{}\t{}", span.id.hyphenated(), span.name, status);
772            }
773            Ok(())
774        }
775    }
776}
777
778fn parse_uuid(s: &str) -> Result<uuid::Uuid> {
779    uuid::Uuid::parse_str(s).map_err(|_| crate::Error::msg(format!("invalid UUID: {s}")))
780}
781
782fn print_messages(messages: &[Message]) {
783    for (i, msg) in messages.iter().enumerate() {
784        println!("{} {}", msg.id.hyphenated(), msg.path.display());
785        println!("{}", msg.body);
786        if i + 1 < messages.len() {
787            println!("---");
788        }
789    }
790}