Skip to main content

rns_git/
work_cli.rs

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