Skip to main content

rns_git/
release_cli.rs

1use std::fs;
2use std::io::{self, Write};
3use std::path::{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 = ReleaseOptions::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 = NetReleaseTransport { client, repository };
33    run_release_command(&mut transport, &options.command, io::stdout())
34}
35
36trait ReleaseTransport {
37    fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>>;
38}
39
40struct NetReleaseTransport {
41    client: SyncClient,
42    repository: String,
43}
44
45impl ReleaseTransport for NetReleaseTransport {
46    fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
47        let response = self.client.request(
48            protocol::PATH_RELEASE,
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 ReleaseOptions {
58    config_dir: Option<PathBuf>,
59    rns_config_dir: Option<PathBuf>,
60    remote: String,
61    command: ReleaseCommand,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65enum ReleaseCommand {
66    List,
67    View {
68        tag: String,
69    },
70    Create {
71        tag: String,
72        artifacts_dir: PathBuf,
73        notes_path: Option<PathBuf>,
74    },
75    Delete {
76        tag: String,
77        yes: bool,
78    },
79}
80
81impl ReleaseOptions {
82    fn parse<I>(args: I) -> Result<Self>
83    where
84        I: IntoIterator<Item = String>,
85    {
86        let mut config_dir = None;
87        let mut rns_config_dir = None;
88        let mut notes_path = None;
89        let mut yes = false;
90        let mut positional = Vec::new();
91        let mut args = args.into_iter();
92        while let Some(arg) = args.next() {
93            match arg.as_str() {
94                "-c" | "--config" => {
95                    config_dir = Some(PathBuf::from(
96                        args.next()
97                            .ok_or_else(|| Error::msg("missing config path"))?,
98                    ));
99                }
100                "--rnsconfig" => {
101                    rns_config_dir = Some(PathBuf::from(
102                        args.next()
103                            .ok_or_else(|| Error::msg("missing RNS config path"))?,
104                    ));
105                }
106                "--notes" => {
107                    notes_path = Some(PathBuf::from(
108                        args.next()
109                            .ok_or_else(|| Error::msg("missing notes path"))?,
110                    ));
111                }
112                "-y" | "--yes" => yes = true,
113                "-h" | "--help" => return Err(Error::msg(usage())),
114                other => positional.push(other.to_string()),
115            }
116        }
117
118        if positional.len() < 2 {
119            return Err(Error::msg(usage()));
120        }
121        let remote = positional[0].clone();
122        let command = match positional[1].as_str() {
123            "list" => {
124                if positional.len() != 2 {
125                    return Err(Error::msg("release list does not take a target"));
126                }
127                ReleaseCommand::List
128            }
129            "view" => ReleaseCommand::View {
130                tag: positional
131                    .get(2)
132                    .cloned()
133                    .ok_or_else(|| Error::msg("release view requires a tag"))?,
134            },
135            "create" => parse_create_target(&positional[2..], notes_path)?,
136            "delete" => ReleaseCommand::Delete {
137                tag: positional
138                    .get(2)
139                    .cloned()
140                    .ok_or_else(|| Error::msg("release delete requires a tag"))?,
141                yes,
142            },
143            other => return Err(Error::msg(format!("unknown release operation {other}"))),
144        };
145        Ok(Self {
146            config_dir,
147            rns_config_dir,
148            remote,
149            command,
150        })
151    }
152}
153
154fn parse_create_target(args: &[String], notes_path: Option<PathBuf>) -> Result<ReleaseCommand> {
155    let first = args
156        .first()
157        .ok_or_else(|| Error::msg("release create requires <tag>:<artifacts-dir>"))?;
158    let (tag, artifacts_dir) = if let Some((tag, dir)) = first.split_once(':') {
159        (tag.to_string(), PathBuf::from(dir))
160    } else {
161        let dir = args
162            .get(1)
163            .ok_or_else(|| Error::msg("release create requires an artifacts directory"))?;
164        (first.clone(), PathBuf::from(dir))
165    };
166    if tag.is_empty() {
167        return Err(Error::msg("release tag cannot be empty"));
168    }
169    Ok(ReleaseCommand::Create {
170        tag,
171        artifacts_dir,
172        notes_path,
173    })
174}
175
176fn run_release_command(
177    transport: &mut impl ReleaseTransport,
178    command: &ReleaseCommand,
179    mut output: impl Write,
180) -> Result<()> {
181    match command {
182        ReleaseCommand::List => {
183            let body = transport.request(request("list", &[]))?;
184            let releases = msgpack::unpack_exact(&body)
185                .map_err(|e| Error::msg(format!("invalid release list: {e}")))?;
186            print_release_list(&releases, &mut output)
187        }
188        ReleaseCommand::View { tag } => {
189            let body = transport.request(request("view", &[("tag", Value::Str(tag.clone()))]))?;
190            let release = msgpack::unpack_exact(&body)
191                .map_err(|e| Error::msg(format!("invalid release view: {e}")))?;
192            print_release_view(&release, &mut output)
193        }
194        ReleaseCommand::Create {
195            tag,
196            artifacts_dir,
197            notes_path,
198        } => create_release(
199            transport,
200            tag,
201            artifacts_dir,
202            notes_path.as_deref(),
203            &mut output,
204        ),
205        ReleaseCommand::Delete { tag, yes } => {
206            if !yes {
207                return Err(Error::msg("release delete requires --yes"));
208            }
209            transport.request(request("delete", &[("tag", Value::Str(tag.clone()))]))?;
210            writeln!(output, "Deleted release {tag}")?;
211            Ok(())
212        }
213    }
214}
215
216fn create_release(
217    transport: &mut impl ReleaseTransport,
218    tag: &str,
219    artifacts_dir: &Path,
220    notes_path: Option<&Path>,
221    mut output: impl Write,
222) -> Result<()> {
223    if !artifacts_dir.is_dir() {
224        return Err(Error::msg(format!(
225            "artifact directory does not exist: {}",
226            artifacts_dir.display()
227        )));
228    }
229    let notes = load_notes(artifacts_dir, notes_path)?;
230    writeln!(output, "Initializing release {tag}")?;
231    transport.request(request(
232        "create",
233        &[
234            ("step", Value::Str("init".into())),
235            ("tag", Value::Str(tag.to_string())),
236            ("notes", Value::Str(notes.content)),
237            ("notes_format", Value::Str(notes.format)),
238        ],
239    ))?;
240
241    let artifacts = artifact_files(artifacts_dir)?;
242    for (index, artifact) in artifacts.iter().enumerate() {
243        let data = fs::read(&artifact.path)?;
244        writeln!(
245            output,
246            "Uploading {} ({}/{}, {} bytes)",
247            artifact.name,
248            index + 1,
249            artifacts.len(),
250            data.len()
251        )?;
252        transport.request(request(
253            "create",
254            &[
255                ("step", Value::Str("artifact".into())),
256                ("tag", Value::Str(tag.to_string())),
257                ("artifact_name", Value::Str(artifact.name.clone())),
258                ("artifact_data", Value::Bin(data)),
259            ],
260        ))?;
261    }
262    writeln!(output, "Finalizing release {tag}")?;
263    transport.request(request(
264        "create",
265        &[
266            ("step", Value::Str("finalize".into())),
267            ("tag", Value::Str(tag.to_string())),
268        ],
269    ))?;
270    writeln!(
271        output,
272        "Created release {tag} with {} artifact(s)",
273        artifacts.len()
274    )?;
275    Ok(())
276}
277
278fn request(operation: &str, fields: &[(&str, Value)]) -> Vec<u8> {
279    let mut map = vec![
280        (
281            Value::UInt(protocol::IDX_REPOSITORY),
282            Value::Str("__repository__".into()),
283        ),
284        (
285            Value::Str("operation".into()),
286            Value::Str(operation.to_string()),
287        ),
288    ];
289    map.extend(
290        fields
291            .iter()
292            .map(|(key, value)| (Value::Str((*key).into()), value.clone())),
293    );
294    msgpack::pack(&Value::Map(map))
295}
296
297fn request_with_repository(data: Vec<u8>, repository: &str) -> Result<Vec<u8>> {
298    let mut value =
299        msgpack::unpack_exact(&data).map_err(|e| Error::msg(format!("invalid request: {e}")))?;
300    if let Value::Map(entries) = &mut value {
301        for (key, val) in entries {
302            if matches!(key, Value::UInt(v) if *v == protocol::IDX_REPOSITORY) {
303                *val = Value::Str(repository.to_string());
304                return Ok(msgpack::pack(&value));
305            }
306        }
307    }
308    Err(Error::msg("release request missing repository field"))
309}
310
311fn usage() -> &'static str {
312    "usage: rngit release [--config DIR] [--rnsconfig DIR] [--notes PATH] [-y|--yes] <rns://destination/repo> <list|view|create|delete> [target]"
313}
314
315struct Notes {
316    content: String,
317    format: String,
318}
319
320fn load_notes(artifacts_dir: &Path, explicit: Option<&Path>) -> Result<Notes> {
321    let path = explicit
322        .map(Path::to_path_buf)
323        .or_else(|| {
324            let micron = artifacts_dir.join("RELEASE.mu");
325            micron.exists().then_some(micron)
326        })
327        .or_else(|| {
328            let markdown = artifacts_dir.join("RELEASE.md");
329            markdown.exists().then_some(markdown)
330        });
331    let Some(path) = path else {
332        return Ok(Notes {
333            content: String::new(),
334            format: "markdown".into(),
335        });
336    };
337    let format = if path.extension().and_then(|ext| ext.to_str()) == Some("mu") {
338        "micron"
339    } else {
340        "markdown"
341    };
342    Ok(Notes {
343        content: fs::read_to_string(path)?,
344        format: format.into(),
345    })
346}
347
348struct ArtifactFile {
349    name: String,
350    path: PathBuf,
351}
352
353fn artifact_files(dir: &Path) -> Result<Vec<ArtifactFile>> {
354    let mut out = Vec::new();
355    for entry in fs::read_dir(dir)? {
356        let entry = entry?;
357        if !entry.file_type()?.is_file() {
358            continue;
359        }
360        let name = entry.file_name().to_string_lossy().into_owned();
361        if matches!(name.as_str(), "RELEASE.md" | "RELEASE.mu") {
362            continue;
363        }
364        out.push(ArtifactFile {
365            name,
366            path: entry.path(),
367        });
368    }
369    out.sort_by(|a, b| a.name.cmp(&b.name));
370    Ok(out)
371}
372
373fn print_release_list(value: &Value, mut output: impl Write) -> Result<()> {
374    let releases = value
375        .as_array()
376        .ok_or_else(|| Error::msg("release list response is not an array"))?;
377    if releases.is_empty() {
378        writeln!(output, "No releases")?;
379        return Ok(());
380    }
381    for release in releases {
382        let tag = release.map_get("tag").and_then(Value::as_str).unwrap_or("");
383        let status = release
384            .map_get("status")
385            .and_then(Value::as_str)
386            .unwrap_or("unknown");
387        let artifacts = release
388            .map_get("artifacts")
389            .and_then(Value::as_integer)
390            .unwrap_or(0);
391        let preview = release
392            .map_get("preview")
393            .and_then(Value::as_str)
394            .unwrap_or("");
395        writeln!(
396            output,
397            "{tag:<16} {status:<10} {artifacts:>3} artifact(s) {preview}"
398        )?;
399    }
400    Ok(())
401}
402
403fn print_release_view(value: &Value, mut output: impl Write) -> Result<()> {
404    let tag = value.map_get("tag").and_then(Value::as_str).unwrap_or("");
405    let status = value
406        .map_get("status")
407        .and_then(Value::as_str)
408        .unwrap_or("unknown");
409    let thanks = value
410        .map_get("thanks")
411        .and_then(Value::as_integer)
412        .unwrap_or(0);
413    writeln!(output, "Release : {tag}")?;
414    writeln!(output, "Status  : {status}")?;
415    writeln!(output, "Thanks  : {thanks}")?;
416    if let Some(notes) = value.map_get("notes").and_then(Value::as_str) {
417        if !notes.is_empty() {
418            writeln!(output, "\n{notes}")?;
419        }
420    }
421    if let Some(artifacts) = value.map_get("artifacts").and_then(Value::as_array) {
422        writeln!(output, "\nArtifacts:")?;
423        for artifact in artifacts {
424            let name = artifact
425                .map_get("name")
426                .and_then(Value::as_str)
427                .unwrap_or("");
428            let size = artifact
429                .map_get("size")
430                .and_then(Value::as_integer)
431                .unwrap_or(0);
432            writeln!(output, "- {name} ({size} bytes)")?;
433        }
434    }
435    Ok(())
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[derive(Default)]
443    struct FakeTransport {
444        responses: Vec<Vec<u8>>,
445        requests: Vec<Value>,
446    }
447
448    impl ReleaseTransport for FakeTransport {
449        fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
450            self.requests.push(msgpack::unpack_exact(&data).unwrap());
451            Ok(self.responses.remove(0))
452        }
453    }
454
455    #[test]
456    fn parses_release_commands() {
457        let list = ReleaseOptions::parse([
458            "rns://00112233445566778899aabbccddeeff/group/repo".into(),
459            "list".into(),
460        ])
461        .unwrap();
462        assert!(matches!(list.command, ReleaseCommand::List));
463
464        let create = ReleaseOptions::parse([
465            "--notes".into(),
466            "NOTES.md".into(),
467            "rns://00112233445566778899aabbccddeeff/group/repo".into(),
468            "create".into(),
469            "v1:dist".into(),
470        ])
471        .unwrap();
472        assert_eq!(
473            create.command,
474            ReleaseCommand::Create {
475                tag: "v1".into(),
476                artifacts_dir: PathBuf::from("dist"),
477                notes_path: Some(PathBuf::from("NOTES.md")),
478            }
479        );
480
481        let delete = ReleaseOptions::parse([
482            "-y".into(),
483            "rns://00112233445566778899aabbccddeeff/group/repo".into(),
484            "delete".into(),
485            "v1".into(),
486        ])
487        .unwrap();
488        assert_eq!(
489            delete.command,
490            ReleaseCommand::Delete {
491                tag: "v1".into(),
492                yes: true
493            }
494        );
495    }
496
497    #[test]
498    fn create_sends_init_artifacts_and_finalize() {
499        let tmp = tempfile::tempdir().unwrap();
500        fs::write(tmp.path().join("RELEASE.md"), "# Notes\n").unwrap();
501        fs::write(tmp.path().join("b.bin"), b"b").unwrap();
502        fs::write(tmp.path().join("a.bin"), b"a").unwrap();
503        let mut fake = FakeTransport {
504            responses: vec![Vec::new(), Vec::new(), Vec::new(), Vec::new()],
505            requests: Vec::new(),
506        };
507        let mut out = Vec::new();
508
509        create_release(&mut fake, "v1", tmp.path(), None, &mut out).unwrap();
510
511        assert_eq!(fake.requests.len(), 4);
512        assert_eq!(
513            fake.requests[0]
514                .map_get("operation")
515                .and_then(Value::as_str),
516            Some("create")
517        );
518        assert_eq!(
519            fake.requests[0].map_get("step").and_then(Value::as_str),
520            Some("init")
521        );
522        assert_eq!(
523            fake.requests[0].map_get("notes").and_then(Value::as_str),
524            Some("# Notes\n")
525        );
526        assert_eq!(
527            fake.requests[1]
528                .map_get("artifact_name")
529                .and_then(Value::as_str),
530            Some("a.bin")
531        );
532        assert_eq!(
533            fake.requests[2]
534                .map_get("artifact_name")
535                .and_then(Value::as_str),
536            Some("b.bin")
537        );
538        assert_eq!(
539            fake.requests[3].map_get("step").and_then(Value::as_str),
540            Some("finalize")
541        );
542        let out = String::from_utf8(out).unwrap();
543        assert!(out.contains("Initializing release v1"));
544        assert!(out.contains("Uploading a.bin (1/2, 1 bytes)"));
545        assert!(out.contains("Uploading b.bin (2/2, 1 bytes)"));
546        assert!(out.contains("Finalizing release v1"));
547        assert!(out.ends_with("Created release v1 with 2 artifact(s)\n"));
548    }
549
550    #[test]
551    fn list_and_view_format_msgpack_responses() {
552        let list_body = Value::Array(vec![Value::Map(vec![
553            (Value::Str("tag".into()), Value::Str("v1".into())),
554            (Value::Str("status".into()), Value::Str("published".into())),
555            (Value::Str("artifacts".into()), Value::UInt(2)),
556            (Value::Str("preview".into()), Value::Str("First".into())),
557        ])]);
558        let mut out = Vec::new();
559        print_release_list(&list_body, &mut out).unwrap();
560        let list = String::from_utf8(out).unwrap();
561        assert!(list.contains("v1"));
562        assert!(list.contains("published"));
563        assert!(list.contains("First"));
564
565        let view_body = Value::Map(vec![
566            (Value::Str("tag".into()), Value::Str("v1".into())),
567            (Value::Str("status".into()), Value::Str("published".into())),
568            (Value::Str("thanks".into()), Value::UInt(3)),
569            (Value::Str("notes".into()), Value::Str("Notes".into())),
570            (
571                Value::Str("artifacts".into()),
572                Value::Array(vec![Value::Map(vec![
573                    (Value::Str("name".into()), Value::Str("dist.tar".into())),
574                    (Value::Str("size".into()), Value::UInt(9)),
575                ])]),
576            ),
577        ]);
578        let mut out = Vec::new();
579        print_release_view(&view_body, &mut out).unwrap();
580        let view = String::from_utf8(out).unwrap();
581        assert!(view.contains("Release : v1"));
582        assert!(view.contains("Thanks  : 3"));
583        assert!(view.contains("dist.tar (9 bytes)"));
584    }
585
586    #[test]
587    fn delete_requires_explicit_yes() {
588        let mut fake = FakeTransport::default();
589        let err = run_release_command(
590            &mut fake,
591            &ReleaseCommand::Delete {
592                tag: "v1".into(),
593                yes: false,
594            },
595            Vec::new(),
596        )
597        .unwrap_err();
598        assert!(err.to_string().contains("--yes"));
599        assert!(fake.requests.is_empty());
600    }
601
602    #[test]
603    fn delete_with_yes_sends_delete_request() {
604        let mut fake = FakeTransport {
605            responses: vec![Vec::new()],
606            requests: Vec::new(),
607        };
608        let mut out = Vec::new();
609        run_release_command(
610            &mut fake,
611            &ReleaseCommand::Delete {
612                tag: "v1".into(),
613                yes: true,
614            },
615            &mut out,
616        )
617        .unwrap();
618
619        assert_eq!(fake.requests.len(), 1);
620        assert_eq!(
621            fake.requests[0]
622                .map_get("operation")
623                .and_then(Value::as_str),
624            Some("delete")
625        );
626        assert_eq!(
627            fake.requests[0].map_get("tag").and_then(Value::as_str),
628            Some("v1")
629        );
630        assert_eq!(String::from_utf8(out).unwrap(), "Deleted release v1\n");
631    }
632}