1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use rns_core::msgpack::{self, Value};
7
8use crate::protocol;
9use crate::util::{hex, validate_repo_name};
10use crate::{Error, Result};
11
12#[derive(Debug, Clone)]
13pub struct ReleaseSummary {
14 pub tag: String,
15 pub hash: String,
16 pub created: u64,
17 pub status: String,
18 pub created_by: String,
19 pub preview: String,
20 pub preview_format: String,
21 pub artifacts: u64,
22}
23
24#[derive(Debug, Clone)]
25pub struct ReleaseData {
26 pub tag: String,
27 pub hash: String,
28 pub created: u64,
29 pub status: String,
30 pub created_by: String,
31 pub notes: String,
32 pub notes_format: String,
33 pub artifacts: Vec<Artifact>,
34 pub thanks: u64,
35}
36
37#[derive(Debug, Clone)]
38pub struct Artifact {
39 pub name: String,
40 pub size: u64,
41}
42
43#[derive(Debug, Clone)]
44pub struct ReleaseRequest {
45 pub repository: String,
46 pub operation: String,
47 pub tag: Option<String>,
48 pub step: Option<String>,
49 pub hash: Option<String>,
50 pub notes: Option<String>,
51 pub notes_format: Option<String>,
52 pub artifact_name: Option<String>,
53 pub artifact_data: Option<Vec<u8>>,
54}
55
56pub fn parse_request(data: &[u8]) -> Result<ReleaseRequest> {
57 let value =
58 msgpack::unpack_exact(data).map_err(|e| Error::msg(format!("invalid msgpack: {e}")))?;
59 let map = value
60 .as_map()
61 .ok_or_else(|| Error::msg("request must be a msgpack map"))?;
62 let repository = map_get_repository(map)
63 .ok_or_else(|| Error::msg("request missing repository"))?
64 .to_string();
65 validate_repo_name(&repository)?;
66 let operation = map_get_str(map, "operation")
67 .ok_or_else(|| Error::msg("request missing operation"))?
68 .to_string();
69 Ok(ReleaseRequest {
70 repository,
71 operation,
72 tag: map_get_str(map, "tag").map(ToOwned::to_owned),
73 step: map_get_str(map, "step").map(ToOwned::to_owned),
74 hash: map_get_str(map, "hash").map(ToOwned::to_owned),
75 notes: map_get_str(map, "notes").map(ToOwned::to_owned),
76 notes_format: map_get_str(map, "notes_format").map(ToOwned::to_owned),
77 artifact_name: map_get_str(map, "artifact_name").map(ToOwned::to_owned),
78 artifact_data: map_get(map, "artifact_data").and_then(value_to_bytes),
79 })
80}
81
82pub fn release_sidecar_path(repo: &Path) -> PathBuf {
83 sidecar_path(repo, "releases")
84}
85
86pub fn list_releases(releases_path: &Path) -> Result<Vec<ReleaseSummary>> {
87 let mut releases = Vec::new();
88 if !releases_path.is_dir() {
89 return Ok(releases);
90 }
91 for entry in fs::read_dir(releases_path)? {
92 let entry = entry?;
93 if !entry.file_type()?.is_dir() {
94 continue;
95 }
96 let release_dir = entry.path();
97 let meta_path = release_dir.join("META");
98 if !meta_path.is_file() {
99 continue;
100 }
101 let meta = read_meta(&meta_path)?;
102 let tag = meta_get(&meta, "tag")
103 .unwrap_or_else(|| entry.file_name().to_string_lossy().into_owned());
104 let (preview, preview_format) = notes_preview(&release_dir)?;
105 releases.push(ReleaseSummary {
106 tag,
107 hash: meta_get(&meta, "hash").unwrap_or_default(),
108 created: meta_get(&meta, "created")
109 .and_then(|v| v.parse().ok())
110 .unwrap_or(0),
111 status: meta_get(&meta, "status").unwrap_or_else(|| "unknown".to_string()),
112 created_by: meta_get(&meta, "created_by").unwrap_or_default(),
113 preview,
114 preview_format,
115 artifacts: artifact_count(&release_dir)?,
116 });
117 }
118 releases.sort_by(|a, b| b.created.cmp(&a.created));
119 Ok(releases)
120}
121
122pub fn release_data(releases_path: &Path, tag: &str) -> Result<Option<ReleaseData>> {
123 let tag = clean_tag(tag).ok_or_else(|| Error::msg("invalid tag name"))?;
124 let release_dir = releases_path.join(&tag);
125 if !release_dir.is_dir() {
126 return Ok(None);
127 }
128 let meta_path = release_dir.join("META");
129 if !meta_path.is_file() {
130 return Ok(None);
131 }
132 let meta = read_meta(&meta_path)?;
133 let (notes, notes_format) = read_notes(&release_dir)?;
134 Ok(Some(ReleaseData {
135 tag: meta_get(&meta, "tag").unwrap_or(tag),
136 hash: meta_get(&meta, "hash").unwrap_or_default(),
137 created: meta_get(&meta, "created")
138 .and_then(|v| v.parse().ok())
139 .unwrap_or(0),
140 status: meta_get(&meta, "status").unwrap_or_else(|| "unknown".to_string()),
141 created_by: meta_get(&meta, "created_by").unwrap_or_default(),
142 notes,
143 notes_format,
144 artifacts: artifacts(&release_dir)?,
145 thanks: thanks_count(&release_dir)?,
146 }))
147}
148
149pub fn latest_published_tag(releases_path: &Path) -> Result<Option<String>> {
150 Ok(list_releases(releases_path)?
151 .into_iter()
152 .filter(|release| release.status == "published")
153 .next()
154 .map(|release| release.tag))
155}
156
157pub fn create_init(
158 releases_path: &Path,
159 repo_path: &Path,
160 request: &ReleaseRequest,
161 remote: &[u8; 16],
162) -> Result<Vec<u8>> {
163 let Some(tag) = request.tag.as_deref().and_then(clean_tag) else {
164 return Ok(protocol::status_bytes(
165 protocol::RES_INVALID_REQ,
166 b"invalid tag name",
167 ));
168 };
169 if let Err(err) = verify_tag(repo_path, &tag) {
170 return Ok(protocol::status_bytes(
171 protocol::RES_INVALID_REQ,
172 err.to_string(),
173 ));
174 }
175 fs::create_dir_all(releases_path)?;
176 let release_dir = releases_path.join(&tag);
177 if release_dir.exists() {
178 return Ok(protocol::status_bytes(
179 protocol::RES_DISALLOWED,
180 b"release already exists",
181 ));
182 }
183 fs::create_dir_all(release_dir.join("artifacts"))?;
184 let created = unix_time();
185 let mut meta = vec![
186 ("tag".to_string(), tag.clone()),
187 ("created".to_string(), created.to_string()),
188 ("status".to_string(), "draft".to_string()),
189 ("created_by".to_string(), hex(remote)),
190 ];
191 if let Some(hash) = request.hash.as_deref().filter(|v| !v.is_empty()) {
192 meta.push(("hash".to_string(), hash.to_string()));
193 }
194 write_meta(&release_dir.join("META"), &meta)?;
195 if let Some(notes) = request.notes.as_deref().filter(|v| !v.is_empty()) {
196 let notes_file = match request.notes_format.as_deref() {
197 Some("micron") => "RELEASE.mu",
198 Some("text") => "RELEASE.txt",
199 _ => "RELEASE.md",
200 };
201 fs::write(release_dir.join(notes_file), notes)?;
202 }
203 write_thanks(&release_dir, 0)?;
204 Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
205}
206
207pub fn create_artifact(releases_path: &Path, request: &ReleaseRequest) -> Result<Vec<u8>> {
208 let Some(tag) = request.tag.as_deref().and_then(clean_tag) else {
209 return Ok(protocol::status_bytes(
210 protocol::RES_INVALID_REQ,
211 b"invalid tag name",
212 ));
213 };
214 let artifact_name = request
215 .artifact_name
216 .as_deref()
217 .and_then(clean_component)
218 .ok_or_else(|| Error::msg("invalid artifact name"))?;
219 let data = request
220 .artifact_data
221 .as_deref()
222 .ok_or_else(|| Error::msg("missing artifact data"))?;
223 let release_dir = releases_path.join(tag);
224 if !release_dir.is_dir() {
225 return Ok(protocol::status_bytes(
226 protocol::RES_NOT_FOUND,
227 b"release not found",
228 ));
229 }
230 let meta_path = release_dir.join("META");
231 let meta = read_meta(&meta_path)?;
232 if meta_get(&meta, "status").as_deref() != Some("draft") {
233 return Ok(protocol::status_bytes(
234 protocol::RES_DISALLOWED,
235 b"release was finalized and is not writable",
236 ));
237 }
238 let artifacts_dir = release_dir.join("artifacts");
239 fs::create_dir_all(&artifacts_dir)?;
240 fs::write(artifacts_dir.join(artifact_name), data)?;
241 Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
242}
243
244pub fn create_finalize(releases_path: &Path, request: &ReleaseRequest) -> Result<Vec<u8>> {
245 let Some(tag) = request.tag.as_deref().and_then(clean_tag) else {
246 return Ok(protocol::status_bytes(
247 protocol::RES_INVALID_REQ,
248 b"invalid tag name",
249 ));
250 };
251 let release_dir = releases_path.join(tag);
252 if !release_dir.is_dir() {
253 return Ok(protocol::status_bytes(
254 protocol::RES_NOT_FOUND,
255 b"release not found",
256 ));
257 }
258 let meta_path = release_dir.join("META");
259 let mut meta = read_meta(&meta_path)?;
260 if meta_get(&meta, "status").as_deref() != Some("draft") {
261 return Ok(protocol::status_bytes(
262 protocol::RES_DISALLOWED,
263 b"release was finalized and is not writable",
264 ));
265 }
266 set_meta(&mut meta, "status", "published");
267 set_meta(&mut meta, "published_at", &unix_time().to_string());
268 write_meta(&meta_path, &meta)?;
269 Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
270}
271
272pub fn delete_release(releases_path: &Path, request: &ReleaseRequest) -> Result<Vec<u8>> {
273 let Some(tag) = request.tag.as_deref().and_then(clean_tag) else {
274 return Ok(protocol::status_bytes(
275 protocol::RES_INVALID_REQ,
276 b"invalid tag name",
277 ));
278 };
279 let release_dir = releases_path.join(tag);
280 if !release_dir.is_dir() {
281 return Ok(protocol::status_bytes(
282 protocol::RES_NOT_FOUND,
283 b"release not found",
284 ));
285 }
286 fs::remove_dir_all(release_dir)?;
287 Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
288}
289
290pub fn list_response(releases_path: &Path) -> Result<Vec<u8>> {
291 Ok(protocol::status_bytes(
292 protocol::RES_OK,
293 msgpack::pack(&Value::Array(
294 list_releases(releases_path)?
295 .into_iter()
296 .map(summary_value)
297 .collect(),
298 )),
299 ))
300}
301
302pub fn view_response(releases_path: &Path, tag: &str) -> Result<Vec<u8>> {
303 let release = match release_data(releases_path, tag) {
304 Ok(release) => release,
305 Err(err) if err.to_string() == "invalid tag name" => {
306 return Ok(protocol::status_bytes(
307 protocol::RES_INVALID_REQ,
308 b"invalid tag name",
309 ));
310 }
311 Err(err) => return Err(err),
312 };
313 match release {
314 Some(release) => Ok(protocol::status_bytes(
315 protocol::RES_OK,
316 msgpack::pack(&data_value(release)),
317 )),
318 None => Ok(protocol::status_bytes(
319 protocol::RES_NOT_FOUND,
320 b"release not found",
321 )),
322 }
323}
324
325pub fn release_thanks(release_dir: &Path, add: bool) -> Result<u64> {
326 let mut count = thanks_count(release_dir)?;
327 if add {
328 count = count.saturating_add(1);
329 write_thanks(release_dir, count)?;
330 }
331 Ok(count)
332}
333
334pub fn artifact_path(releases_path: &Path, tag: &str, artifact: &str) -> Result<Option<PathBuf>> {
335 let tag = if tag == "latest" {
336 let Some(tag) = latest_published_tag(releases_path)? else {
337 return Ok(None);
338 };
339 tag
340 } else {
341 clean_tag(tag).ok_or_else(|| Error::msg("invalid tag name"))?
342 };
343 let artifact = clean_component(artifact).ok_or_else(|| Error::msg("invalid artifact name"))?;
344 let Some(release) = release_data(releases_path, &tag)? else {
345 return Ok(None);
346 };
347 if release.status != "published" {
348 return Ok(None);
349 }
350 let path = releases_path.join(tag).join("artifacts").join(artifact);
351 Ok(path.is_file().then_some(path))
352}
353
354fn summary_value(release: ReleaseSummary) -> Value {
355 Value::Map(vec![
356 (Value::Str("tag".into()), Value::Str(release.tag)),
357 (Value::Str("hash".into()), Value::Str(release.hash)),
358 (Value::Str("created".into()), Value::UInt(release.created)),
359 (Value::Str("status".into()), Value::Str(release.status)),
360 (
361 Value::Str("created_by".into()),
362 Value::Str(release.created_by),
363 ),
364 (Value::Str("preview".into()), Value::Str(release.preview)),
365 (
366 Value::Str("preview_format".into()),
367 Value::Str(release.preview_format),
368 ),
369 (
370 Value::Str("artifacts".into()),
371 Value::UInt(release.artifacts),
372 ),
373 ])
374}
375
376fn data_value(release: ReleaseData) -> Value {
377 Value::Map(vec![
378 (Value::Str("tag".into()), Value::Str(release.tag)),
379 (Value::Str("hash".into()), Value::Str(release.hash)),
380 (Value::Str("created".into()), Value::UInt(release.created)),
381 (Value::Str("status".into()), Value::Str(release.status)),
382 (
383 Value::Str("created_by".into()),
384 Value::Str(release.created_by),
385 ),
386 (Value::Str("notes".into()), Value::Str(release.notes)),
387 (
388 Value::Str("notes_format".into()),
389 Value::Str(release.notes_format),
390 ),
391 (
392 Value::Str("artifacts".into()),
393 Value::Array(release.artifacts.into_iter().map(artifact_value).collect()),
394 ),
395 (Value::Str("thanks".into()), Value::UInt(release.thanks)),
396 ])
397}
398
399fn artifact_value(artifact: Artifact) -> Value {
400 Value::Map(vec![
401 (Value::Str("name".into()), Value::Str(artifact.name)),
402 (Value::Str("size".into()), Value::UInt(artifact.size)),
403 ])
404}
405
406fn sidecar_path(repo: &Path, extension: &str) -> PathBuf {
407 let mut name = repo.as_os_str().to_os_string();
408 name.push(format!(".{extension}"));
409 PathBuf::from(name)
410}
411
412fn read_meta(path: &Path) -> Result<Vec<(String, String)>> {
413 let input = fs::read_to_string(path)?;
414 let mut out = Vec::new();
415 for raw in input.lines() {
416 let line = raw.trim();
417 if line.is_empty() || line.starts_with('#') {
418 continue;
419 }
420 if let Some((key, value)) = line.split_once('=') {
421 out.push((key.trim().to_string(), value.trim().to_string()));
422 }
423 }
424 Ok(out)
425}
426
427fn write_meta(path: &Path, values: &[(String, String)]) -> Result<()> {
428 let mut out = String::new();
429 for (key, value) in values {
430 out.push_str(key);
431 out.push_str(" = ");
432 out.push_str(value);
433 out.push('\n');
434 }
435 fs::write(path, out)?;
436 Ok(())
437}
438
439fn meta_get(values: &[(String, String)], key: &str) -> Option<String> {
440 values
441 .iter()
442 .find_map(|(k, v)| (k == key).then(|| v.clone()))
443}
444
445fn set_meta(values: &mut Vec<(String, String)>, key: &str, value: &str) {
446 if let Some((_, existing)) = values.iter_mut().find(|(k, _)| k == key) {
447 *existing = value.to_string();
448 } else {
449 values.push((key.to_string(), value.to_string()));
450 }
451}
452
453fn read_notes(release_dir: &Path) -> Result<(String, String)> {
454 for (file, format) in [
455 ("RELEASE.md", "markdown"),
456 ("RELEASE.mu", "micron"),
457 ("RELEASE.txt", "text"),
458 ] {
459 let path = release_dir.join(file);
460 if path.is_file() {
461 return Ok((fs::read_to_string(path)?, format.to_string()));
462 }
463 }
464 Ok((String::new(), "text".to_string()))
465}
466
467fn notes_preview(release_dir: &Path) -> Result<(String, String)> {
468 let (notes, format) = read_notes(release_dir)?;
469 let first = notes
470 .lines()
471 .map(str::trim)
472 .find(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('>'))
473 .unwrap_or("");
474 Ok((first.chars().take(256).collect(), format))
475}
476
477fn artifacts(release_dir: &Path) -> Result<Vec<Artifact>> {
478 let artifacts_dir = release_dir.join("artifacts");
479 let mut out = Vec::new();
480 if !artifacts_dir.is_dir() {
481 return Ok(out);
482 }
483 for entry in fs::read_dir(artifacts_dir)? {
484 let entry = entry?;
485 if !entry.file_type()?.is_file() {
486 continue;
487 }
488 out.push(Artifact {
489 name: entry.file_name().to_string_lossy().into_owned(),
490 size: entry.metadata()?.len(),
491 });
492 }
493 out.sort_by(|a, b| a.name.cmp(&b.name));
494 Ok(out)
495}
496
497fn artifact_count(release_dir: &Path) -> Result<u64> {
498 Ok(artifacts(release_dir)?.len() as u64)
499}
500
501fn thanks_count(release_dir: &Path) -> Result<u64> {
502 let path = release_dir.join("THANKS");
503 if !path.is_file() {
504 return Ok(0);
505 }
506 let bytes = fs::read(path)?;
507 if let Ok(value) = msgpack::unpack_exact(&bytes) {
508 return Ok(value
509 .map_get("count")
510 .and_then(Value::as_integer)
511 .unwrap_or(0)
512 .max(0) as u64);
513 }
514 Ok(String::from_utf8_lossy(&bytes).trim().parse().unwrap_or(0))
515}
516
517fn write_thanks(release_dir: &Path, count: u64) -> Result<()> {
518 fs::write(
519 release_dir.join("THANKS"),
520 msgpack::pack(&Value::Map(vec![(
521 Value::Str("count".into()),
522 Value::UInt(count),
523 )])),
524 )?;
525 Ok(())
526}
527
528fn verify_tag(repo_path: &Path, tag: &str) -> Result<()> {
529 let output = Command::new("git")
530 .arg("--git-dir")
531 .arg(repo_path)
532 .arg("rev-parse")
533 .arg("--verify")
534 .arg(format!("refs/tags/{tag}"))
535 .output()?;
536 if output.status.success() {
537 Ok(())
538 } else {
539 Err(Error::msg(format!(
540 "tag '{tag}' does not exist in repository"
541 )))
542 }
543}
544
545fn unix_time() -> u64 {
546 SystemTime::now()
547 .duration_since(UNIX_EPOCH)
548 .unwrap_or_default()
549 .as_secs()
550}
551
552fn clean_component(value: &str) -> Option<String> {
553 let name = Path::new(value).file_name()?.to_str()?.trim();
554 (!name.is_empty() && name != "." && name != "..").then(|| name.to_string())
555}
556
557fn clean_tag(value: &str) -> Option<String> {
558 if value.contains('/') || value.contains('\\') {
559 return None;
560 }
561 clean_component(value)
562}
563
564fn map_get_repository<'a>(map: &'a [(Value, Value)]) -> Option<&'a str> {
565 map.iter().find_map(|(key, value)| match key {
566 Value::UInt(v) if *v == protocol::IDX_REPOSITORY => value.as_str(),
567 Value::Str(v) if v == "repository" => value.as_str(),
568 _ => None,
569 })
570}
571
572fn map_get<'a>(map: &'a [(Value, Value)], key: &str) -> Option<&'a Value> {
573 map.iter().find_map(|(k, v)| match k {
574 Value::Str(s) if s == key => Some(v),
575 _ => None,
576 })
577}
578
579fn map_get_str<'a>(map: &'a [(Value, Value)], key: &str) -> Option<&'a str> {
580 map_get(map, key).and_then(Value::as_str)
581}
582
583fn value_to_bytes(value: &Value) -> Option<Vec<u8>> {
584 match value {
585 Value::Bin(bytes) => Some(bytes.clone()),
586 Value::Str(text) => Some(text.as_bytes().to_vec()),
587 _ => None,
588 }
589}