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}