Skip to main content

rns_git/
work_cli.rs

1use std::fs;
2use std::io::{self, Write};
3use std::path::PathBuf;
4
5use rns_core::msgpack::{self, Value};
6
7use crate::client::{decode_status, SyncClient};
8use crate::config::ClientConfig;
9use crate::logging;
10use crate::protocol;
11use crate::util::{default_reticulum_dir, default_rngit_dir, parse_rns_url};
12use crate::{Error, Result};
13
14pub fn main<I>(args: I) -> Result<()>
15where
16    I: IntoIterator<Item = String>,
17{
18    let options = WorkOptions::parse(args)?;
19    let (dest_hash, repository) = parse_rns_url(&options.remote)?;
20    let rngit_dir = options.config_dir.unwrap_or_else(default_rngit_dir);
21    let rns_dir = options.rns_config_dir.or_else(default_reticulum_dir);
22    let (config, created) = ClientConfig::load_or_create(rngit_dir, rns_dir)?;
23    logging::init_file_logger(&config.dir.join("client_log"), config.log_level)?;
24    if created {
25        return Err(Error::msg(format!(
26            "created default config at {}; edit it and run again",
27            config.dir.join("client_config").display()
28        )));
29    }
30
31    let client = SyncClient::connect(config, dest_hash)?;
32    let mut transport = NetWorkTransport { client, repository };
33    run_work_command(&mut transport, &options.command, io::stdout())
34}
35
36trait WorkTransport {
37    fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>>;
38}
39
40struct NetWorkTransport {
41    client: SyncClient,
42    repository: String,
43}
44
45impl WorkTransport for NetWorkTransport {
46    fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
47        let response = self.client.request(
48            protocol::PATH_WORK,
49            request_with_repository(data, &self.repository)?,
50        )?;
51        let bytes = protocol::response_bin(&response.data)?;
52        decode_status(bytes)
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57struct WorkOptions {
58    config_dir: Option<PathBuf>,
59    rns_config_dir: Option<PathBuf>,
60    remote: String,
61    command: WorkCommand,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65enum WorkCommand {
66    List {
67        scope: String,
68    },
69    View {
70        scope: String,
71        id: u64,
72    },
73    Create {
74        title: String,
75        content_path: PathBuf,
76    },
77    Edit {
78        scope: String,
79        id: u64,
80        title: Option<String>,
81        content_path: Option<PathBuf>,
82    },
83    Delete {
84        scope: String,
85        id: u64,
86        yes: bool,
87    },
88    Comment {
89        scope: String,
90        id: u64,
91        content_path: PathBuf,
92    },
93    Perms {
94        id: u64,
95        content_path: Option<PathBuf>,
96    },
97    Complete {
98        id: u64,
99    },
100    Activate {
101        id: u64,
102    },
103}
104
105impl WorkOptions {
106    fn parse<I>(args: I) -> Result<Self>
107    where
108        I: IntoIterator<Item = String>,
109    {
110        let mut config_dir = None;
111        let mut rns_config_dir = None;
112        let mut scope = "active".to_string();
113        let mut id = None;
114        let mut title = None;
115        let mut content_path = None;
116        let mut yes = false;
117        let mut positional = Vec::new();
118        let mut args = args.into_iter();
119        while let Some(arg) = args.next() {
120            match arg.as_str() {
121                "-c" | "--config" => {
122                    config_dir = Some(PathBuf::from(
123                        args.next()
124                            .ok_or_else(|| Error::msg("missing config path"))?,
125                    ));
126                }
127                "--rnsconfig" => {
128                    rns_config_dir = Some(PathBuf::from(
129                        args.next()
130                            .ok_or_else(|| Error::msg("missing RNS config path"))?,
131                    ));
132                }
133                "--scope" => {
134                    scope = args.next().ok_or_else(|| Error::msg("missing scope"))?;
135                }
136                "-d" | "--id" => {
137                    id = Some(parse_id(
138                        &args
139                            .next()
140                            .ok_or_else(|| Error::msg("missing document ID"))?,
141                    )?);
142                }
143                "-t" | "--title" => {
144                    title = Some(args.next().ok_or_else(|| Error::msg("missing title"))?);
145                }
146                "--content" => {
147                    content_path = Some(PathBuf::from(
148                        args.next()
149                            .ok_or_else(|| Error::msg("missing content path"))?,
150                    ));
151                }
152                "-y" | "--yes" => yes = true,
153                "-h" | "--help" => return Err(Error::msg(usage())),
154                other => positional.push(other.to_string()),
155            }
156        }
157        if positional.len() < 2 {
158            return Err(Error::msg(usage()));
159        }
160        let remote = positional[0].clone();
161        let command = match positional[1].as_str() {
162            "list" => WorkCommand::List { scope },
163            "view" => WorkCommand::View {
164                scope,
165                id: id
166                    .or_else(|| positional.get(2).and_then(|value| parse_id(value).ok()))
167                    .ok_or_else(|| Error::msg("work view requires --id"))?,
168            },
169            "create" => WorkCommand::Create {
170                title: title.ok_or_else(|| Error::msg("work create requires --title"))?,
171                content_path: content_path
172                    .ok_or_else(|| Error::msg("work create requires --content"))?,
173            },
174            "edit" => WorkCommand::Edit {
175                scope,
176                id: id.ok_or_else(|| Error::msg("work edit requires --id"))?,
177                title,
178                content_path,
179            },
180            "delete" => WorkCommand::Delete {
181                scope,
182                id: id.ok_or_else(|| Error::msg("work delete requires --id"))?,
183                yes,
184            },
185            "comment" | "update" => WorkCommand::Comment {
186                scope,
187                id: id.ok_or_else(|| Error::msg("work comment requires --id"))?,
188                content_path: content_path
189                    .ok_or_else(|| Error::msg("work comment requires --content"))?,
190            },
191            "perms" => WorkCommand::Perms {
192                id: id.ok_or_else(|| Error::msg("work perms requires --id"))?,
193                content_path,
194            },
195            "complete" => WorkCommand::Complete {
196                id: id.ok_or_else(|| Error::msg("work complete requires --id"))?,
197            },
198            "activate" => WorkCommand::Activate {
199                id: id.ok_or_else(|| Error::msg("work activate requires --id"))?,
200            },
201            other => return Err(Error::msg(format!("unknown work operation {other}"))),
202        };
203        Ok(Self {
204            config_dir,
205            rns_config_dir,
206            remote,
207            command,
208        })
209    }
210}
211
212fn run_work_command(
213    transport: &mut impl WorkTransport,
214    command: &WorkCommand,
215    mut output: impl Write,
216) -> Result<()> {
217    match command {
218        WorkCommand::List { scope } => {
219            let body =
220                transport.request(request("list", &[("scope", Value::Str(scope.clone()))]))?;
221            let value = msgpack::unpack_exact(&body)
222                .map_err(|e| Error::msg(format!("invalid work list: {e}")))?;
223            print_work_list(&value, &mut output)
224        }
225        WorkCommand::View { scope, id } => {
226            let body = transport.request(request(
227                "view",
228                &[
229                    ("scope", Value::Str(scope.clone())),
230                    ("doc_id", Value::UInt(*id)),
231                ],
232            ))?;
233            let value = msgpack::unpack_exact(&body)
234                .map_err(|e| Error::msg(format!("invalid work document: {e}")))?;
235            print_work_document(&value, &mut output)
236        }
237        WorkCommand::Create {
238            title,
239            content_path,
240        } => {
241            let content = fs::read_to_string(content_path)?;
242            let body = transport.request(request(
243                "create",
244                &[
245                    ("title", Value::Str(title.clone())),
246                    ("content", Value::Str(content)),
247                    ("format", Value::Str(format_for_path(content_path))),
248                ],
249            ))?;
250            let value = msgpack::unpack_exact(&body)
251                .map_err(|e| Error::msg(format!("invalid create response: {e}")))?;
252            writeln!(
253                output,
254                "Created work document {} #{}",
255                value
256                    .map_get("scope")
257                    .and_then(Value::as_str)
258                    .unwrap_or("active"),
259                value.map_get("id").and_then(Value::as_integer).unwrap_or(0)
260            )?;
261            Ok(())
262        }
263        WorkCommand::Edit {
264            scope,
265            id,
266            title,
267            content_path,
268        } => {
269            let mut fields = vec![
270                ("scope", Value::Str(scope.clone())),
271                ("doc_id", Value::UInt(*id)),
272            ];
273            if let Some(title) = title {
274                fields.push(("title", Value::Str(title.clone())));
275            }
276            if let Some(path) = content_path {
277                fields.push(("content", Value::Str(fs::read_to_string(path)?)));
278            }
279            transport.request(request("edit", &fields))?;
280            writeln!(output, "Updated work document {scope} #{id}")?;
281            Ok(())
282        }
283        WorkCommand::Delete { scope, id, yes } => {
284            if !yes {
285                return Err(Error::msg("work delete requires --yes"));
286            }
287            transport.request(request(
288                "delete",
289                &[
290                    ("scope", Value::Str(scope.clone())),
291                    ("doc_id", Value::UInt(*id)),
292                ],
293            ))?;
294            writeln!(output, "Deleted work document {scope} #{id}")?;
295            Ok(())
296        }
297        WorkCommand::Comment {
298            scope,
299            id,
300            content_path,
301        } => {
302            let body = transport.request(request(
303                "comment",
304                &[
305                    ("scope", Value::Str(scope.clone())),
306                    ("doc_id", Value::UInt(*id)),
307                    ("content", Value::Str(fs::read_to_string(content_path)?)),
308                    ("format", Value::Str(format_for_path(content_path))),
309                ],
310            ))?;
311            let value = msgpack::unpack_exact(&body)
312                .map_err(|e| Error::msg(format!("invalid comment response: {e}")))?;
313            writeln!(
314                output,
315                "Added update #{} to {scope} document #{id}",
316                value.map_get("id").and_then(Value::as_integer).unwrap_or(0)
317            )?;
318            Ok(())
319        }
320        WorkCommand::Perms { id, content_path } => {
321            if let Some(path) = content_path {
322                transport.request(request(
323                    "perms",
324                    &[
325                        ("doc_id", Value::UInt(*id)),
326                        ("step", Value::Str("set".into())),
327                        ("content", Value::Str(fs::read_to_string(path)?)),
328                    ],
329                ))?;
330                writeln!(output, "Updated permissions for work document #{id}")?;
331            } else {
332                let body = transport.request(request(
333                    "perms",
334                    &[
335                        ("doc_id", Value::UInt(*id)),
336                        ("step", Value::Str("get".into())),
337                    ],
338                ))?;
339                let value = msgpack::unpack_exact(&body)
340                    .map_err(|e| Error::msg(format!("invalid permissions response: {e}")))?;
341                write!(
342                    output,
343                    "{}",
344                    value
345                        .map_get("content")
346                        .and_then(Value::as_str)
347                        .unwrap_or("")
348                )?;
349            }
350            Ok(())
351        }
352        WorkCommand::Complete { id } => {
353            transport.request(request("complete", &[("doc_id", Value::UInt(*id))]))?;
354            writeln!(output, "Completed work document #{id}")?;
355            Ok(())
356        }
357        WorkCommand::Activate { id } => {
358            transport.request(request("activate", &[("doc_id", Value::UInt(*id))]))?;
359            writeln!(output, "Activated work document #{id}")?;
360            Ok(())
361        }
362    }
363}
364
365fn request(operation: &str, fields: &[(&str, Value)]) -> Vec<u8> {
366    let mut map = vec![
367        (
368            Value::UInt(protocol::IDX_REPOSITORY),
369            Value::Str("__repository__".into()),
370        ),
371        (
372            Value::Str("operation".into()),
373            Value::Str(operation.to_string()),
374        ),
375    ];
376    map.extend(
377        fields
378            .iter()
379            .map(|(key, value)| (Value::Str((*key).into()), value.clone())),
380    );
381    msgpack::pack(&Value::Map(map))
382}
383
384fn request_with_repository(data: Vec<u8>, repository: &str) -> Result<Vec<u8>> {
385    let mut value =
386        msgpack::unpack_exact(&data).map_err(|e| Error::msg(format!("invalid request: {e}")))?;
387    if let Value::Map(entries) = &mut value {
388        for (key, val) in entries {
389            if matches!(key, Value::UInt(v) if *v == protocol::IDX_REPOSITORY) {
390                *val = Value::Str(repository.to_string());
391                return Ok(msgpack::pack(&value));
392            }
393        }
394    }
395    Err(Error::msg("work request missing repository field"))
396}
397
398fn print_work_list(value: &Value, mut output: impl Write) -> Result<()> {
399    let active = value
400        .map_get("active")
401        .and_then(Value::as_array)
402        .unwrap_or(&[]);
403    let completed = value
404        .map_get("completed")
405        .and_then(Value::as_array)
406        .unwrap_or(&[]);
407    if active.is_empty() && completed.is_empty() {
408        writeln!(output, "No work documents")?;
409        return Ok(());
410    }
411    print_work_section("Active documents", active, &mut output)?;
412    print_work_section("Completed documents", completed, &mut output)?;
413    Ok(())
414}
415
416fn print_work_section(title: &str, docs: &[Value], mut output: impl Write) -> Result<()> {
417    if docs.is_empty() {
418        return Ok(());
419    }
420    writeln!(output, "{title}")?;
421    for doc in docs {
422        writeln!(
423            output,
424            "#{} {} ({} update(s))",
425            doc.map_get("id").and_then(Value::as_integer).unwrap_or(0),
426            doc.map_get("title")
427                .and_then(Value::as_str)
428                .unwrap_or("Untitled"),
429            doc.map_get("comments")
430                .and_then(Value::as_integer)
431                .unwrap_or(0)
432        )?;
433    }
434    Ok(())
435}
436
437fn print_work_document(value: &Value, mut output: impl Write) -> Result<()> {
438    let title = value
439        .map_get("meta")
440        .and_then(|meta| meta.map_get("title"))
441        .and_then(Value::as_str)
442        .unwrap_or("Untitled");
443    writeln!(
444        output,
445        "{} (#{} {})",
446        title,
447        value.map_get("id").and_then(Value::as_integer).unwrap_or(0),
448        value
449            .map_get("scope")
450            .and_then(Value::as_str)
451            .unwrap_or("active")
452    )?;
453    writeln!(
454        output,
455        "{}",
456        value
457            .map_get("content")
458            .and_then(Value::as_str)
459            .unwrap_or("")
460    )?;
461    let comments = value
462        .map_get("comments")
463        .and_then(Value::as_array)
464        .unwrap_or(&[]);
465    for comment in comments {
466        writeln!(
467            output,
468            "\nUpdate #{}\n{}",
469            comment
470                .map_get("id")
471                .and_then(Value::as_integer)
472                .unwrap_or(0),
473            comment
474                .map_get("content")
475                .and_then(Value::as_str)
476                .unwrap_or("")
477        )?;
478    }
479    Ok(())
480}
481
482fn parse_id(value: &str) -> Result<u64> {
483    value.parse().map_err(|_| Error::msg("invalid document ID"))
484}
485
486fn format_for_path(path: &std::path::Path) -> String {
487    if path.extension().and_then(|ext| ext.to_str()) == Some("mu") {
488        "micron".into()
489    } else {
490        "markdown".into()
491    }
492}
493
494fn usage() -> &'static str {
495    "usage: rngit work [--config DIR] [--rnsconfig DIR] [--scope active|completed|all] [--id N] [--title TITLE] [--content PATH] [-y|--yes] <rns://destination/repo> <list|view|create|edit|delete|comment|perms|complete|activate>"
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[derive(Default)]
503    struct FakeTransport {
504        requests: Vec<Value>,
505        responses: Vec<Vec<u8>>,
506    }
507
508    impl WorkTransport for FakeTransport {
509        fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
510            self.requests.push(msgpack::unpack_exact(&data).unwrap());
511            Ok(self.responses.remove(0))
512        }
513    }
514
515    #[test]
516    fn parses_work_commands() {
517        assert_eq!(
518            WorkOptions::parse(args(&[
519                "rns://00112233445566778899aabbccddeeff/group/repo",
520                "list",
521                "--scope",
522                "all",
523            ]))
524            .unwrap()
525            .command,
526            WorkCommand::List {
527                scope: "all".into()
528            }
529        );
530        assert_eq!(
531            WorkOptions::parse(args(&[
532                "rns://00112233445566778899aabbccddeeff/group/repo",
533                "view",
534                "--id",
535                "7",
536            ]))
537            .unwrap()
538            .command,
539            WorkCommand::View {
540                scope: "active".into(),
541                id: 7,
542            }
543        );
544        assert!(WorkOptions::parse(args(&[
545            "rns://00112233445566778899aabbccddeeff/group/repo",
546            "create",
547            "--title",
548            "Task",
549        ]))
550        .is_err());
551        assert_eq!(
552            WorkOptions::parse(args(&[
553                "rns://00112233445566778899aabbccddeeff/group/repo",
554                "perms",
555                "--id",
556                "7",
557            ]))
558            .unwrap()
559            .command,
560            WorkCommand::Perms {
561                id: 7,
562                content_path: None,
563            }
564        );
565    }
566
567    #[test]
568    fn create_sends_work_create_request() {
569        let tmp = tempfile::tempdir().unwrap();
570        let content = tmp.path().join("WORK.md");
571        fs::write(&content, "# Body\n").unwrap();
572        let mut transport = FakeTransport {
573            requests: Vec::new(),
574            responses: vec![msgpack::pack(&Value::Map(vec![
575                (Value::Str("id".into()), Value::UInt(3)),
576                (Value::Str("scope".into()), Value::Str("active".into())),
577            ]))],
578        };
579        let mut output = Vec::new();
580        run_work_command(
581            &mut transport,
582            &WorkCommand::Create {
583                title: "Task".into(),
584                content_path: content,
585            },
586            &mut output,
587        )
588        .unwrap();
589        assert!(String::from_utf8(output).unwrap().contains("active #3"));
590        assert_eq!(
591            transport.requests[0]
592                .map_get("operation")
593                .and_then(Value::as_str),
594            Some("create")
595        );
596        assert_eq!(
597            transport.requests[0]
598                .map_get("content")
599                .and_then(Value::as_str),
600            Some("# Body\n")
601        );
602    }
603
604    #[test]
605    fn list_and_view_format_responses() {
606        let mut transport = FakeTransport {
607            requests: Vec::new(),
608            responses: vec![
609                msgpack::pack(&Value::Map(vec![(
610                    Value::Str("active".into()),
611                    Value::Array(vec![Value::Map(vec![
612                        (Value::Str("id".into()), Value::UInt(1)),
613                        (Value::Str("title".into()), Value::Str("Task".into())),
614                        (Value::Str("comments".into()), Value::UInt(2)),
615                    ])]),
616                )])),
617                msgpack::pack(&Value::Map(vec![
618                    (Value::Str("id".into()), Value::UInt(1)),
619                    (Value::Str("scope".into()), Value::Str("active".into())),
620                    (Value::Str("content".into()), Value::Str("Body".into())),
621                    (
622                        Value::Str("meta".into()),
623                        Value::Map(vec![(
624                            Value::Str("title".into()),
625                            Value::Str("Task".into()),
626                        )]),
627                    ),
628                    (
629                        Value::Str("comments".into()),
630                        Value::Array(vec![Value::Map(vec![
631                            (Value::Str("id".into()), Value::UInt(1)),
632                            (Value::Str("content".into()), Value::Str("Update".into())),
633                        ])]),
634                    ),
635                ])),
636            ],
637        };
638        let mut output = Vec::new();
639        run_work_command(
640            &mut transport,
641            &WorkCommand::List {
642                scope: "active".into(),
643            },
644            &mut output,
645        )
646        .unwrap();
647        run_work_command(
648            &mut transport,
649            &WorkCommand::View {
650                scope: "active".into(),
651                id: 1,
652            },
653            &mut output,
654        )
655        .unwrap();
656        let output = String::from_utf8(output).unwrap();
657        assert!(output.contains("#1 Task (2 update(s))"));
658        assert!(output.contains("Task (#1 active)"));
659        assert!(output.contains("Update #1"));
660    }
661
662    #[test]
663    fn delete_requires_yes_and_lifecycle_commands_send_requests() {
664        let tmp = tempfile::tempdir().unwrap();
665        let comment_path = tmp.path().join("COMMENT.md");
666        fs::write(&comment_path, "Update").unwrap();
667        let mut transport = FakeTransport {
668            requests: Vec::new(),
669            responses: vec![
670                msgpack::pack(&Value::Map(vec![(Value::Str("id".into()), Value::UInt(1))])),
671                Vec::new(),
672                Vec::new(),
673                Vec::new(),
674            ],
675        };
676        assert!(run_work_command(
677            &mut transport,
678            &WorkCommand::Delete {
679                scope: "active".into(),
680                id: 1,
681                yes: false,
682            },
683            Vec::new(),
684        )
685        .is_err());
686        run_work_command(
687            &mut transport,
688            &WorkCommand::Comment {
689                scope: "active".into(),
690                id: 1,
691                content_path: comment_path,
692            },
693            Vec::new(),
694        )
695        .unwrap();
696        run_work_command(&mut transport, &WorkCommand::Complete { id: 1 }, Vec::new()).unwrap();
697        run_work_command(&mut transport, &WorkCommand::Activate { id: 1 }, Vec::new()).unwrap();
698        run_work_command(
699            &mut transport,
700            &WorkCommand::Delete {
701                scope: "active".into(),
702                id: 1,
703                yes: true,
704            },
705            Vec::new(),
706        )
707        .unwrap();
708        let ops: Vec<_> = transport
709            .requests
710            .iter()
711            .filter_map(|request| request.map_get("operation").and_then(Value::as_str))
712            .collect();
713        assert_eq!(ops, vec!["comment", "complete", "activate", "delete"]);
714    }
715
716    #[test]
717    fn perms_get_and_set_send_work_permission_requests() {
718        let tmp = tempfile::tempdir().unwrap();
719        let permissions = tmp.path().join("allowed.txt");
720        fs::write(&permissions, "interact = all\n").unwrap();
721        let mut transport = FakeTransport {
722            requests: Vec::new(),
723            responses: vec![
724                msgpack::pack(&Value::Map(vec![(
725                    Value::Str("content".into()),
726                    Value::Str("admin = all\n".into()),
727                )])),
728                Vec::new(),
729            ],
730        };
731        let mut output = Vec::new();
732        run_work_command(
733            &mut transport,
734            &WorkCommand::Perms {
735                id: 3,
736                content_path: None,
737            },
738            &mut output,
739        )
740        .unwrap();
741        run_work_command(
742            &mut transport,
743            &WorkCommand::Perms {
744                id: 3,
745                content_path: Some(permissions),
746            },
747            &mut output,
748        )
749        .unwrap();
750        assert!(String::from_utf8(output).unwrap().contains("admin = all"));
751        assert_eq!(
752            transport.requests[0]
753                .map_get("operation")
754                .and_then(Value::as_str),
755            Some("perms")
756        );
757        assert_eq!(
758            transport.requests[0]
759                .map_get("step")
760                .and_then(Value::as_str),
761            Some("get")
762        );
763        assert_eq!(
764            transport.requests[1]
765                .map_get("step")
766                .and_then(Value::as_str),
767            Some("set")
768        );
769        assert_eq!(
770            transport.requests[1]
771                .map_get("content")
772                .and_then(Value::as_str),
773            Some("interact = all\n")
774        );
775    }
776
777    fn args(values: &[&str]) -> Vec<String> {
778        values.iter().map(|value| value.to_string()).collect()
779    }
780}