Skip to main content

rns_git/
release.rs

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}