Skip to main content

rns_git/
pages.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output, Stdio};
5use std::thread;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use rns_core::msgpack::{self, Value};
9use rns_core::types::IdentityHash;
10use rns_crypto::identity::Identity;
11use rns_net::link_manager::ResourceStrategy;
12use rns_net::{Destination, RequestResponse, RnsNode};
13
14use crate::acl::{Access, Operation};
15use crate::config::ServerConfig;
16use crate::protocol;
17use crate::util::validate_repo_name;
18use crate::{Error, Result};
19
20pub const APP_NAME: &str = "nomadnetwork";
21pub const ASPECT_NODE: &str = "node";
22
23pub const PATH_INDEX: &str = "/page/index.mu";
24pub const PATH_GROUP: &str = "/page/group.mu";
25pub const PATH_REPO: &str = "/page/repo.mu";
26pub const PATH_TREE: &str = "/page/tree.mu";
27pub const PATH_BLOB: &str = "/page/blob.mu";
28pub const PATH_COMMITS: &str = "/page/commits.mu";
29pub const PATH_COMMIT: &str = "/page/commit.mu";
30pub const PATH_REFS: &str = "/page/refs.mu";
31pub const PATH_STATS: &str = "/page/stats.mu";
32pub const PATH_RELEASES: &str = "/page/releases.mu";
33pub const PATH_RELEASE: &str = "/page/release.mu";
34pub const PATH_WORK: &str = "/page/work.mu";
35pub const PATH_WORK_DOC: &str = "/page/work_doc.mu";
36pub const PATH_DOWNLOAD: &str = "/file/download";
37pub const PATH_WORK_DOC_DOWNLOAD: &str = "/file/workdoc";
38
39const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(8);
40const RCLR_PUSH: &str = "B9A810";
41const RCLR_PUSH_G: &str = "791212";
42const RCLR_FETCH: &str = "10b981";
43const RCLR_FETCH_G: &str = "1c5e71";
44const RCLR_VIEW: &str = "3b82f6";
45const RCLR_VIEW_G: &str = "13428A";
46const RCLR_DOWNLOAD: &str = "7831E0";
47const RCLR_DOWNLOAD_G: &str = "c5754d";
48
49const PAGE_PATHS: &[&str] = &[
50    PATH_INDEX,
51    PATH_GROUP,
52    PATH_REPO,
53    PATH_TREE,
54    PATH_BLOB,
55    PATH_COMMITS,
56    PATH_COMMIT,
57    PATH_REFS,
58    PATH_STATS,
59    PATH_RELEASES,
60    PATH_RELEASE,
61    PATH_WORK,
62    PATH_WORK_DOC,
63];
64
65pub fn destination_for_identity(identity: &Identity) -> Destination {
66    Destination::single_in(APP_NAME, &[ASPECT_NODE], IdentityHash(*identity.hash()))
67}
68
69pub fn register_nomadnet_destination(
70    node: &RnsNode,
71    config: &ServerConfig,
72    identity: &Identity,
73) -> Result<Destination> {
74    let destination = destination_for_identity(identity);
75    let public_key = identity
76        .get_public_key()
77        .ok_or_else(|| Error::msg("repository identity has no public key"))?;
78    let private_key = identity
79        .get_private_key()
80        .ok_or_else(|| Error::msg("repository identity has no private key"))?;
81    let sig_prv: [u8; 32] = private_key[32..64].try_into().unwrap();
82    let sig_pub: [u8; 32] = public_key[32..64].try_into().unwrap();
83
84    node.register_link_destination(
85        destination.hash.0,
86        sig_prv,
87        sig_pub,
88        ResourceStrategy::AcceptAll as u8,
89    )
90    .map_err(|_| Error::msg("failed to register Nomad Network page destination"))?;
91
92    let access = Access::new_with_aliases(
93        &config.allow_read,
94        &config.allow_write,
95        &config.allow_create,
96        &config.allow_stats,
97        &config.allow_release,
98        &config.allow_interact,
99        &config.allow_admin,
100        config.repositories_dir.clone(),
101        config.identity_aliases.clone(),
102    )?
103    .with_propose(&config.allow_propose)?
104    .with_blocked_identities(config.blocked_identities.clone());
105    register_page_handlers(node, config.clone(), access)?;
106
107    Ok(destination)
108}
109
110fn register_page_handlers(node: &RnsNode, config: ServerConfig, access: Access) -> Result<()> {
111    for path in PAGE_PATHS {
112        let handler_path = *path;
113        let handler_config = config.clone();
114        let handler_access = access.clone();
115        node.register_request_handler(handler_path, None, move |_link, path, data, remote| {
116            let remote_hash = remote.map(|(hash, _)| hash);
117            Some(
118                match render_page(path, &handler_config, &handler_access, data, remote_hash) {
119                    Ok(page) => page.into_bytes(),
120                    Err(err) => error_page(&handler_config.node_name, &err.to_string()),
121                },
122            )
123        })
124        .map_err(|_| Error::msg(format!("failed to register page handler {handler_path}")))?;
125    }
126    let download_config = config.clone();
127    let download_access = access.clone();
128    node.register_request_handler_response(
129        PATH_DOWNLOAD,
130        None,
131        move |_link, _path, data, remote| {
132            let remote_hash = remote.map(|(hash, _)| hash);
133            Some(
134                download_file(&download_config, &download_access, data, remote_hash)
135                    .unwrap_or_else(|err| RequestResponse::Bytes(error_response_bytes(&err))),
136            )
137        },
138    )
139    .map_err(|_| Error::msg("failed to register file download handler"))?;
140    let workdoc_download_config = config.clone();
141    let workdoc_download_access = access.clone();
142    node.register_request_handler_response(
143        PATH_WORK_DOC_DOWNLOAD,
144        None,
145        move |_link, _path, data, remote| {
146            let remote_hash = remote.map(|(hash, _)| hash);
147            Some(
148                download_work_document(
149                    &workdoc_download_config,
150                    &workdoc_download_access,
151                    data,
152                    remote_hash,
153                )
154                .unwrap_or_else(|err| RequestResponse::Bytes(error_response_bytes(&err))),
155            )
156        },
157    )
158    .map_err(|_| Error::msg("failed to register work document download handler"))?;
159    Ok(())
160}
161
162pub fn render_page(
163    path: &str,
164    config: &ServerConfig,
165    access: &Access,
166    data: &[u8],
167    remote: Option<&[u8; 16]>,
168) -> Result<String> {
169    let vars = parse_page_vars(data)?;
170    let (template, content) = match path {
171        PATH_INDEX => ("front", render_front_page(config, access, remote)?),
172        PATH_GROUP => ("group", render_group_page(config, access, remote, &vars)?),
173        PATH_REPO => ("repo", render_repo_page(config, access, remote, &vars)?),
174        PATH_TREE => ("tree", render_tree_page(config, access, remote, &vars)?),
175        PATH_BLOB => ("blob", render_blob_page(config, access, remote, &vars)?),
176        PATH_COMMITS => (
177            "commits",
178            render_commits_page(config, access, remote, &vars)?,
179        ),
180        PATH_COMMIT => ("commit", render_commit_page(config, access, remote, &vars)?),
181        PATH_REFS => ("refs", render_refs_page(config, access, remote, &vars)?),
182        PATH_STATS => ("stats", render_stats_page(config, access, remote, &vars)?),
183        PATH_RELEASES => (
184            "releases",
185            render_releases_page(config, access, remote, &vars)?,
186        ),
187        PATH_RELEASE => (
188            "release",
189            render_release_page(config, access, remote, &vars)?,
190        ),
191        PATH_WORK => ("work", render_work_page(config, access, remote, &vars)?),
192        PATH_WORK_DOC => (
193            "work_doc",
194            render_work_doc_page(config, access, remote, &vars)?,
195        ),
196        _ => return Err(Error::msg("unknown page path")),
197    };
198    record_page_view(path, config, &vars, remote);
199    Ok(render_template(config, template, &content))
200}
201
202fn record_page_view(
203    path: &str,
204    config: &ServerConfig,
205    vars: &BTreeMap<String, String>,
206    remote: Option<&[u8; 16]>,
207) {
208    match path {
209        PATH_INDEX => crate::stats::record_front_view(config, remote),
210        PATH_GROUP => {
211            if let Some(group) = var(vars, "g") {
212                crate::stats::record_group_view(config, group, remote);
213            }
214        }
215        PATH_REPO | PATH_TREE | PATH_BLOB | PATH_COMMITS | PATH_COMMIT | PATH_REFS
216        | PATH_RELEASES | PATH_RELEASE | PATH_WORK | PATH_WORK_DOC => {
217            if let (Some(group), Some(repo)) = (var(vars, "g"), var(vars, "r")) {
218                crate::stats::record_repository_view(config, group, repo, remote);
219            }
220        }
221        _ => {}
222    }
223}
224
225fn parse_page_vars(data: &[u8]) -> Result<BTreeMap<String, String>> {
226    if data.is_empty() {
227        return Ok(BTreeMap::new());
228    }
229    let value =
230        msgpack::unpack_exact(data).map_err(|e| Error::msg(format!("invalid msgpack: {e}")))?;
231    let map = value
232        .as_map()
233        .ok_or_else(|| Error::msg("page request must be a msgpack map"))?;
234    let mut out = BTreeMap::new();
235    for (key, value) in map {
236        let Some(key) = key.as_str() else {
237            continue;
238        };
239        let value = match value {
240            Value::Str(value) => value.clone(),
241            Value::UInt(value) => value.to_string(),
242            Value::Int(value) => value.to_string(),
243            Value::Bool(value) => {
244                if *value {
245                    "true".to_string()
246                } else {
247                    "false".to_string()
248                }
249            }
250            _ => continue,
251        };
252        out.insert(key.to_string(), value);
253    }
254    Ok(out)
255}
256
257fn render_template(config: &ServerConfig, template: &str, content: &str) -> String {
258    let page = apply_template(&page_template(config, template), content, config);
259    apply_template(&base_template(config), &page, config)
260}
261
262fn error_page(node_name: &str, message: &str) -> Vec<u8> {
263    default_base_template()
264        .replace("{PAGE_CONTENT}", &format!(">Error\n\n{message}\n"))
265        .replace("{NODE_NAME}", &m_escape(node_name))
266        .replace("{VERSION}", env!("CARGO_PKG_VERSION"))
267        .replace("{NAVIGATION}", "")
268        .replace("{GEN_TIME}", "0ms")
269        .into_bytes()
270}
271
272fn error_response_bytes(err: &Error) -> Vec<u8> {
273    protocol::status_bytes(protocol::RES_INVALID_REQ, err.to_string())
274}
275
276fn base_template(config: &ServerConfig) -> String {
277    load_template(config, "base").unwrap_or_else(|| default_base_template().to_string())
278}
279
280fn page_template(config: &ServerConfig, template: &str) -> String {
281    load_template(config, template).unwrap_or_else(|| "{PAGE_CONTENT}".to_string())
282}
283
284fn load_template(config: &ServerConfig, template: &str) -> Option<String> {
285    if !template
286        .chars()
287        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
288    {
289        return None;
290    }
291    let path = config.templates_dir.join(format!("{template}.mu"));
292    fs::read_to_string(path)
293        .ok()
294        .map(|template| template.trim_end().to_string())
295}
296
297fn apply_template(template: &str, content: &str, config: &ServerConfig) -> String {
298    template
299        .replace("{NODE_NAME}", &m_escape(&config.node_name))
300        .replace("{VERSION}", env!("CARGO_PKG_VERSION"))
301        .replace("{NAVIGATION}", "")
302        .replace("{GEN_TIME}", "0ms")
303        .replace("{PAGE_CONTENT}", content)
304}
305
306fn default_base_template() -> &'static str {
307    "#!c=0\n>{NODE_NAME}\n\n{PAGE_CONTENT}\n<\n-\n`a`F666`[Served by rngit`:/page/index.mu] - Generated by rngit`f\n"
308}
309
310fn icon(config: &ServerConfig, name: &str) -> &'static str {
311    if !config.unicode_icons {
312        return "";
313    }
314    match name {
315        "sep" => "•",
316        "folder" => "🗀",
317        "file" => "🗎",
318        "branch" => "⑃",
319        "tag" => "⌆",
320        "commits" => "🖹",
321        "package" => "▣",
322        "stats" => "🗠",
323        "heart" => "♥",
324        _ => "",
325    }
326}
327
328fn icon_label(config: &ServerConfig, name: &str, label: &str) -> String {
329    let icon = icon(config, name);
330    if icon.is_empty() {
331        label.to_string()
332    } else {
333        format!("{icon} {label}")
334    }
335}
336
337fn icon_sep(config: &ServerConfig) -> &'static str {
338    let sep = icon(config, "sep");
339    if sep.is_empty() {
340        "•"
341    } else {
342        sep
343    }
344}
345
346fn m_link(label: &str, path: &str, fields: &[(&str, &str)]) -> String {
347    format!("`!{}`!", m_link_raw(label, path, fields))
348}
349
350fn m_link_raw(label: &str, path: &str, fields: &[(&str, &str)]) -> String {
351    let mut out = String::from("`[");
352    out.push_str(&sanitize_label(label));
353    out.push_str("`:");
354    out.push_str(path);
355    if !fields.is_empty() {
356        out.push('`');
357        for (i, (key, value)) in fields.iter().enumerate() {
358            if i > 0 {
359                out.push('|');
360            }
361            out.push_str(key);
362            out.push('=');
363            out.push_str(&sanitize_field(value));
364        }
365    }
366    out.push(']');
367    out
368}
369
370fn markdown_blob_url_scope(group: &str, repo: &str, reference: &str, current_path: &str) -> String {
371    let directory = current_path
372        .rsplit_once('/')
373        .map(|(directory, _)| format!("{directory}/"))
374        .unwrap_or_default();
375    let mut out = format!(":{PATH_BLOB}`");
376    for (index, (key, value)) in [
377        ("g", group),
378        ("r", repo),
379        ("ref", reference),
380        ("path", directory.as_str()),
381    ]
382    .iter()
383    .enumerate()
384    {
385        if index > 0 {
386            out.push('|');
387        }
388        out.push_str(key);
389        out.push('=');
390        out.push_str(&sanitize_field(value));
391    }
392    out
393}
394
395fn sanitize_label(value: &str) -> String {
396    value
397        .chars()
398        .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
399        .collect()
400}
401
402fn sanitize_field(value: &str) -> String {
403    let mut out = String::new();
404    for byte in value.bytes() {
405        match byte {
406            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'/' => {
407                out.push(byte as char)
408            }
409            b' ' => out.push('+'),
410            _ => out.push_str(&format!("%{byte:02X}")),
411        }
412    }
413    out
414}
415
416fn m_escape(value: &str) -> String {
417    value
418        .replace('\\', "\\\\")
419        .replace('`', "\\`")
420        .replace('\t', "   ")
421}
422
423fn m_escape_line_start_controls(value: &str) -> String {
424    let escaped = m_escape(value);
425    if value.starts_with('-') || value.starts_with('>') || value.starts_with('<') {
426        format!("\\{escaped}")
427    } else {
428        escaped
429    }
430}
431
432fn format_commit_message(value: &str) -> String {
433    value
434        .split('\n')
435        .map(|line| {
436            let escaped = m_escape(line);
437            if line.starts_with('-') {
438                format!("\\{escaped}")
439            } else {
440                escaped
441            }
442        })
443        .collect::<Vec<_>>()
444        .join("\n")
445}
446
447fn join_git_path(parent: &str, child: &str) -> String {
448    if parent.is_empty() {
449        child.to_string()
450    } else {
451        format!("{parent}/{child}")
452    }
453}
454
455fn format_size(size: u64) -> String {
456    const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB"];
457    let mut value = size as f64;
458    let mut unit = 0;
459    while value >= 1024.0 && unit + 1 < UNITS.len() {
460        value /= 1024.0;
461        unit += 1;
462    }
463    if unit == 0 {
464        format!("{size} B")
465    } else {
466        format!("{value:.1} {}", UNITS[unit])
467    }
468}
469
470fn format_unix_time(timestamp: i64) -> String {
471    timestamp.to_string()
472}
473
474fn render_front_page(
475    config: &ServerConfig,
476    access: &Access,
477    remote: Option<&[u8; 16]>,
478) -> Result<String> {
479    let groups = accessible_groups(config, access, remote)?;
480    let mut out = format!(">>\n{} /\n\n>Groups\n\n", m_link("Node", PATH_INDEX, &[]));
481    if groups.is_empty() {
482        out.push_str("No repository groups available.\n");
483        return Ok(out);
484    }
485    for (group, repos) in groups {
486        out.push_str(&format!(
487            "{} ({} repositories)\n",
488            m_link(&group, PATH_GROUP, &[("g", &group)]),
489            repos.len()
490        ));
491        for repo in repos {
492            out.push_str(&format!(
493                "  - {}\n",
494                m_link_raw(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
495            ));
496        }
497    }
498    Ok(out)
499}
500
501fn render_group_page(
502    config: &ServerConfig,
503    access: &Access,
504    remote: Option<&[u8; 16]>,
505    vars: &BTreeMap<String, String>,
506) -> Result<String> {
507    let group = required_var(vars, "g")?;
508    validate_group_name(group)?;
509    let repos = accessible_repositories(config, access, remote, group)?;
510    if repos.is_empty() {
511        return Ok(">Group Not Found\n\nThe requested group was not found.\n".to_string());
512    }
513
514    let mut out = format!(
515        ">>\n{} / {}\n\n>Repositories\n\n",
516        m_link("Node", PATH_INDEX, &[]),
517        m_escape(group)
518    );
519    for repo in repos {
520        let repo_path = config.repositories_dir.join(group).join(&repo);
521        let description = repository_description(&repo_path)?;
522        out.push_str(&format!(
523            "- {}",
524            m_link(&repo, PATH_REPO, &[("g", group), ("r", &repo)])
525        ));
526        if !description.is_empty() {
527            out.push_str(&format!(" - {}", m_escape(&description)));
528        }
529        out.push('\n');
530    }
531    Ok(out)
532}
533
534fn render_repo_page(
535    config: &ServerConfig,
536    access: &Access,
537    remote: Option<&[u8; 16]>,
538    vars: &BTreeMap<String, String>,
539) -> Result<String> {
540    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
541    let description = repository_description(&repository)?;
542    let origin = repository_origin(&repository)?;
543    let refs = git_refs(&repository)?;
544    let readme = readme_content(&repository)?;
545    let repo_url = format!("rns://<repository-destination>/{group}/{repo}");
546    let thanks = repository_thanks(&repository, var(vars, "thanks").is_some())?;
547    let releases_path = crate::release::release_sidecar_path(&repository);
548    let release_count = crate::release::list_releases(&releases_path)?
549        .into_iter()
550        .filter(|release| release.status == "published")
551        .count();
552    let work_path = crate::work::work_sidecar_path(&repository);
553    let work_lists = crate::work::list_documents(&work_path, crate::work::WorkListScope::All)?;
554    let work_count = work_lists.active.len() + work_lists.completed.len();
555
556    let branch_count = refs.heads.len().to_string();
557    let tag_count = refs.tags.len().to_string();
558    let mut out = format!(
559        ">>\n{} / {} / {} `F666{}`f\n",
560        m_link("Node", PATH_INDEX, &[]),
561        m_link(&group, PATH_GROUP, &[("g", &group)]),
562        m_escape(&repo),
563        m_escape(&repo_url)
564    );
565    if let Some(origin) = origin {
566        out.push_str(&format!(
567            "`F666{} from {}`f",
568            origin.label(),
569            m_escape(&origin.source)
570        ));
571        if let Some(synced_at) = origin.synced_at {
572            out.push_str(&format!(
573                " `*`F666synced {} ago`f`*",
574                format_sync_age(synced_at)
575            ));
576        }
577        out.push('\n');
578    }
579    out.push('\n');
580    if !description.is_empty() {
581        out.push_str(&format!("{}\n\n", m_escape(&description)));
582    }
583    let mut nav_links = vec![
584        m_link_raw(
585            &icon_label(config, "folder", "Files"),
586            PATH_TREE,
587            &[("g", &group), ("r", &repo), ("ref", "HEAD")],
588        ),
589        m_link_raw(
590            &icon_label(config, "commits", "Commits"),
591            PATH_COMMITS,
592            &[("g", &group), ("r", &repo), ("ref", "HEAD")],
593        ),
594        m_link_raw(
595            &icon_label(config, "branch", &format!("Branches ({branch_count})")),
596            PATH_REFS,
597            &[("g", &group), ("r", &repo), ("type", "heads")],
598        ),
599        m_link_raw(
600            &icon_label(config, "tag", &format!("Tags ({tag_count})")),
601            PATH_REFS,
602            &[("g", &group), ("r", &repo), ("type", "tags")],
603        ),
604        m_link_raw(
605            &icon_label(config, "heart", &format!("Thanks ({thanks})")),
606            PATH_REPO,
607            &[("g", &group), ("r", &repo), ("thanks", "y")],
608        ),
609    ];
610    if release_count > 0 {
611        nav_links.push(m_link_raw(
612            &icon_label(config, "package", &format!("Releases ({release_count})")),
613            PATH_RELEASES,
614            &[("g", &group), ("r", &repo)],
615        ));
616    }
617    if work_count > 0 {
618        nav_links.push(m_link_raw(
619            &icon_label(config, "work", &format!("Work ({work_count})")),
620            PATH_WORK,
621            &[("g", &group), ("r", &repo)],
622        ));
623    }
624    if access.allows(Operation::Stats, &format!("{group}/{repo}"), remote)? {
625        nav_links.push(m_link_raw(
626            &icon_label(config, "stats", "Stats"),
627            PATH_STATS,
628            &[("g", &group), ("r", &repo)],
629        ));
630    }
631    let sep = format!(" {} ", icon_sep(config));
632    out.push_str(&format!("{}\n\n", nav_links.join(&sep)));
633    if let Some(readme) = readme {
634        if !readme.content.trim_start().starts_with('>') {
635            out.push_str("-\n");
636        }
637        if readme.markdown {
638            out.push_str(&markdown_to_micron_scoped(
639                &readme.content,
640                Some(&markdown_blob_url_scope(&group, &repo, "HEAD", "README.md")),
641            ));
642            if !out.ends_with('\n') {
643                out.push('\n');
644            }
645        } else {
646            out.push_str(readme.content.trim_end());
647            out.push('\n');
648        }
649    } else {
650        out.push_str("No README file found in this repository.\n");
651    }
652    Ok(out)
653}
654
655fn render_tree_page(
656    config: &ServerConfig,
657    access: &Access,
658    remote: Option<&[u8; 16]>,
659    vars: &BTreeMap<String, String>,
660) -> Result<String> {
661    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
662    let reference = var(vars, "ref").unwrap_or("HEAD");
663    let path = var(vars, "path").unwrap_or("");
664    validate_git_path(path)?;
665    let resolved = resolve_ref(&repository, reference)?;
666    let entries = tree_entries(&repository, &resolved, path)?;
667    let title_path = if path.is_empty() { "/" } else { path };
668
669    let mut out = format!(
670        ">>\n{} / {} / {} / files / {}\n\n>Contents: {} ({})\n\n",
671        m_link("Node", PATH_INDEX, &[]),
672        m_link(&group, PATH_GROUP, &[("g", &group)]),
673        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
674        m_escape(title_path),
675        m_escape(reference),
676        &resolved[..8]
677    );
678    if entries.is_empty() {
679        out.push_str("Empty directory.\n");
680    } else {
681        for entry in entries {
682            let entry_path = join_git_path(path, &entry.name);
683            if entry.kind == "tree" {
684                let label = icon_label(config, "folder", &format!("{}/", entry.name));
685                out.push_str(&format!(
686                    "{}\n",
687                    m_link_raw(
688                        &label,
689                        PATH_TREE,
690                        &[
691                            ("g", &group),
692                            ("r", &repo),
693                            ("ref", reference),
694                            ("path", &entry_path)
695                        ]
696                    )
697                ));
698            } else {
699                let label = icon_label(config, "file", &entry.name);
700                out.push_str(&format!(
701                    "{} {}\n",
702                    m_link_raw(
703                        &label,
704                        PATH_BLOB,
705                        &[
706                            ("g", &group),
707                            ("r", &repo),
708                            ("ref", reference),
709                            ("path", &entry_path)
710                        ]
711                    ),
712                    entry.size_label()
713                ));
714            }
715        }
716    }
717    Ok(out)
718}
719
720fn render_blob_page(
721    config: &ServerConfig,
722    access: &Access,
723    remote: Option<&[u8; 16]>,
724    vars: &BTreeMap<String, String>,
725) -> Result<String> {
726    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
727    let reference = var(vars, "ref").unwrap_or("HEAD");
728    let path = normalize_blob_path(required_var(vars, "path")?);
729    validate_git_path(&path)?;
730    let resolved = resolve_ref(&repository, reference)?;
731    if git_object_kind(&repository, &resolved, &path)? == "tree" {
732        return render_tree_page(config, access, remote, vars);
733    }
734    let blob = blob_info(&repository, &resolved, &path)?;
735    let renderable = renderable_blob(&path);
736    let raw_requested = var(vars, "raw").is_some();
737    let render_requested = var(vars, "render").is_some();
738    let render =
739        renderable.is_some() && !raw_requested && (render_requested || render_by_default(&path));
740    let download_link = m_link(
741        "Download",
742        PATH_DOWNLOAD,
743        &[
744            ("g", &group),
745            ("r", &repo),
746            ("ref", reference),
747            ("path", path.as_str()),
748        ],
749    );
750    let controls = if renderable.is_some() {
751        let render_link = m_link(
752            "View rendered",
753            PATH_BLOB,
754            &[
755                ("g", &group),
756                ("r", &repo),
757                ("ref", reference),
758                ("path", path.as_str()),
759                ("render", "y"),
760            ],
761        );
762        let raw_link = m_link(
763            "View raw",
764            PATH_BLOB,
765            &[
766                ("g", &group),
767                ("r", &repo),
768                ("ref", reference),
769                ("path", path.as_str()),
770                ("raw", "y"),
771            ],
772        );
773        let sep = icon_sep(config);
774        Some(if render {
775            format!("Displaying Rendered {sep} {raw_link}")
776        } else {
777            format!("Displaying Raw {sep} {render_link} {sep} {download_link}")
778        })
779    } else {
780        Some(format!(
781            "Displaying Raw {} {download_link}",
782            icon_sep(config)
783        ))
784    };
785    let content = if !blob.displayable {
786        crate::highlight::plain_literal_block(&blob.content)
787    } else if render {
788        match renderable {
789            Some(RenderableBlob::Markdown) => markdown_to_micron_scoped(
790                &blob.content,
791                Some(&markdown_blob_url_scope(&group, &repo, reference, &path)),
792            ),
793            Some(RenderableBlob::Micron) => {
794                let mut out = blob.content.clone();
795                if !out.ends_with('\n') {
796                    out.push('\n');
797                }
798                out
799            }
800            None => crate::highlight::literal_block(&blob.content, Some(&path), None),
801        }
802    } else {
803        crate::highlight::literal_block(&blob.content, Some(&path), None)
804    };
805    let controls = controls
806        .map(|controls| format!("{controls}\n\n"))
807        .unwrap_or_default();
808    Ok(format!(
809        ">>\n{} / {} / {} / {}\n\n>{} `F666{} ({}, {})`f\n\n{}{}",
810        m_link("Node", PATH_INDEX, &[]),
811        m_link(&group, PATH_GROUP, &[("g", &group)]),
812        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
813        m_escape(&path),
814        m_escape(&path),
815        &resolved[..8],
816        if blob.binary { "Binary" } else { "Text" },
817        format_size(blob.size),
818        controls,
819        content
820    ))
821}
822
823#[derive(Debug, Clone, Copy)]
824enum RenderableBlob {
825    Markdown,
826    Micron,
827}
828
829fn renderable_blob(path: &str) -> Option<RenderableBlob> {
830    match Path::new(path)
831        .extension()
832        .and_then(|extension| extension.to_str())
833        .map(str::to_ascii_lowercase)
834        .as_deref()
835    {
836        Some("md") => Some(RenderableBlob::Markdown),
837        Some("mu") => Some(RenderableBlob::Micron),
838        _ => None,
839    }
840}
841
842fn render_by_default(path: &str) -> bool {
843    renderable_blob(path).is_some()
844}
845
846fn render_commits_page(
847    config: &ServerConfig,
848    access: &Access,
849    remote: Option<&[u8; 16]>,
850    vars: &BTreeMap<String, String>,
851) -> Result<String> {
852    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
853    let reference = var(vars, "ref").unwrap_or("HEAD");
854    let resolved = resolve_ref(&repository, reference)?;
855    let commits = commits(&repository, &resolved, 100)?;
856
857    let mut out = format!(
858        ">>\n{} / {} / {} / commits\n\n>Commits `F666{} ({})`f\n\n",
859        m_link("Node", PATH_INDEX, &[]),
860        m_link(&group, PATH_GROUP, &[("g", &group)]),
861        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
862        m_escape(reference),
863        &resolved[..8]
864    );
865    if commits.is_empty() {
866        out.push_str("No commits found.\n");
867    } else {
868        for commit in commits {
869            out.push_str(&format!(
870                "{} {} `F666{}`f\n{}\n\n",
871                m_link_raw(
872                    &commit.hash[..7],
873                    PATH_COMMIT,
874                    &[
875                        ("g", &group),
876                        ("r", &repo),
877                        ("ref", reference),
878                        ("h", &commit.hash),
879                    ],
880                ),
881                m_escape(&commit.author),
882                format_unix_time(commit.timestamp),
883                m_escape(&commit.subject)
884            ));
885        }
886    }
887    Ok(out)
888}
889
890fn render_commit_page(
891    config: &ServerConfig,
892    access: &Access,
893    remote: Option<&[u8; 16]>,
894    vars: &BTreeMap<String, String>,
895) -> Result<String> {
896    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
897    let reference = var(vars, "ref").unwrap_or("HEAD");
898    let _resolved = resolve_ref(&repository, reference)?;
899    let hash = required_var(vars, "h")?;
900    validate_refish(hash)?;
901    let commit = commit_info(&repository, hash)?;
902    let signature = commit_signature_status(&repository, hash)?;
903    let commits_link = m_link(
904        "commits",
905        PATH_COMMITS,
906        &[("g", &group), ("r", &repo), ("ref", reference)],
907    );
908    let mut out = format!(
909        ">>\n{} / {} / {} / {} / commit {}\n\n>Commit {}\n\n{}\nAuthor: {} <{}>\nDate: {}\n",
910        m_link("Node", PATH_INDEX, &[]),
911        m_link(&group, PATH_GROUP, &[("g", &group)]),
912        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
913        commits_link,
914        &commit.hash[..7],
915        commit.hash,
916        m_escape(&commit.subject),
917        m_escape(&commit.author),
918        m_escape(&commit.email),
919        format_unix_time(commit.timestamp)
920    );
921    if let Some(signature) = signature {
922        out.push_str(&format!("Signature: {}\n", m_escape(&signature)));
923    }
924    if !commit.body.is_empty() {
925        out.push_str(&format!("\n{}\n", format_commit_message(&commit.body)));
926    }
927    out.push_str("\n>Files changed\n\n");
928    for file in commit.files {
929        out.push_str(&format!("{} {}\n", file.status, m_escape(&file.path)));
930    }
931    Ok(out)
932}
933
934fn commit_signature_status(repo: &Path, hash: &str) -> Result<Option<String>> {
935    let commit = raw_commit_object(repo, hash)?;
936    let (Some(signature), signed_content) = crate::commitsigs::split_commit_signature(&commit)
937    else {
938        return Ok(None);
939    };
940    let status = crate::commitsigs::verify_message_signature(&signature, &signed_content, None);
941    if status.valid && status.author_match {
942        Ok(Some("Valid, signed by author".into()))
943    } else if status.valid {
944        Ok(Some(status.message))
945    } else {
946        Ok(Some(format!("Invalid signature: {}", status.message)))
947    }
948}
949
950fn render_refs_page(
951    config: &ServerConfig,
952    access: &Access,
953    remote: Option<&[u8; 16]>,
954    vars: &BTreeMap<String, String>,
955) -> Result<String> {
956    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
957    let refs = git_refs(&repository)?;
958    let mut out = format!(
959        ">>\n{} / {} / {} / refs\n\n>Refs\n\n>Branches\n",
960        m_link("Node", PATH_INDEX, &[]),
961        m_link(&group, PATH_GROUP, &[("g", &group)]),
962        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
963    );
964    for branch in refs.heads {
965        out.push_str(&format!(
966            "- {} `F666{}`f\n",
967            m_link_raw(
968                &branch.name,
969                PATH_TREE,
970                &[("g", &group), ("r", &repo), ("ref", &branch.name)]
971            ),
972            &branch.sha[..8]
973        ));
974    }
975    out.push_str("\n>Tags\n");
976    for tag in refs.tags {
977        out.push_str(&format!(
978            "- {} `F666{}`f\n",
979            m_link_raw(
980                &tag.name,
981                PATH_TREE,
982                &[("g", &group), ("r", &repo), ("ref", &tag.name)]
983            ),
984            &tag.sha[..8]
985        ));
986    }
987    Ok(out)
988}
989
990fn render_stats_page(
991    config: &ServerConfig,
992    access: &Access,
993    remote: Option<&[u8; 16]>,
994    vars: &BTreeMap<String, String>,
995) -> Result<String> {
996    let (group, repo, _repository) = accessible_repository(config, access, remote, vars)?;
997    if !access.allows(Operation::Stats, &format!("{group}/{repo}"), remote)? {
998        return Ok(">>\n>Error\n\nThe requested repository was not found.\n".to_string());
999    }
1000    let Some(stats) = crate::stats::repository_stats(config, access, remote, &group, &repo, 90)?
1001    else {
1002        return Ok(
1003            ">>\n>Stats Unavailable\n\nCould not retrieve statistics for this repository.\n"
1004                .to_string(),
1005        );
1006    };
1007
1008    let mut out = format!(
1009        ">>\n{} / {} / {} / stats\n\n>Stats for {}\n\n",
1010        m_link("Node", PATH_INDEX, &[]),
1011        m_link(&group, PATH_GROUP, &[("g", &group)]),
1012        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1013        m_escape(&stats.repository)
1014    );
1015    out.push_str(&format!(
1016        "`FT{RCLR_FETCH}Fetches`f   : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1017        stats.fetches.total,
1018        stats.fetches.daily.last().copied().unwrap_or(0),
1019        stats.fetches.peak
1020    ));
1021    out.push_str(&format!(
1022        "`FT{RCLR_PUSH}Pushes`f    : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1023        stats.pushes.total,
1024        stats.pushes.daily.last().copied().unwrap_or(0),
1025        stats.pushes.peak
1026    ));
1027    out.push_str(&format!(
1028        "`FT{RCLR_VIEW}Views`f     : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1029        stats.views.total,
1030        stats.views.daily.last().copied().unwrap_or(0),
1031        stats.views.peak
1032    ));
1033    out.push_str(&format!(
1034        "`FT{RCLR_DOWNLOAD}Downloads`f : {:>5}  total `F666  today: {:>3}  peak: {:>3} `f\n",
1035        stats.downloads_combined.total,
1036        stats.downloads_combined.daily.last().copied().unwrap_or(0),
1037        stats.downloads_combined.peak
1038    ));
1039    out.push_str(&format!(
1040        "`F0aaActivity`f  : {:>5} points\n\n",
1041        stats.activity_score
1042    ));
1043    out.push_str(&format!(
1044        "{}{}`f over the last {} days ({})\n\n",
1045        stats.activity_level.color(),
1046        stats.activity_level.label(),
1047        stats.actual_days,
1048        stats.date_range
1049    ));
1050
1051    if stats.fetches.total > 0 {
1052        out.push_str(">Fetches\n\n");
1053        out.push_str(&render_chart(
1054            &stats.fetches.daily,
1055            &stats.timeline_labels,
1056            RCLR_FETCH,
1057            Some(RCLR_FETCH_G),
1058            1.3,
1059            10,
1060        ));
1061        out.push('\n');
1062    }
1063    if stats.pushes.total > 0 {
1064        out.push_str(">Pushes\n\n");
1065        out.push_str(&render_chart(
1066            &stats.pushes.daily,
1067            &stats.timeline_labels,
1068            RCLR_PUSH,
1069            Some(RCLR_PUSH_G),
1070            1.3,
1071            10,
1072        ));
1073        out.push('\n');
1074    }
1075    if stats.views.total > 0 {
1076        out.push_str(">Views\n\n");
1077        out.push_str(&render_chart(
1078            &stats.views.daily,
1079            &stats.timeline_labels,
1080            RCLR_VIEW,
1081            Some(RCLR_VIEW_G),
1082            1.3,
1083            10,
1084        ));
1085        out.push('\n');
1086    }
1087    if stats.downloads_combined.total > 0 {
1088        out.push_str(">Downloads\n\n");
1089        out.push_str(&render_chart(
1090            &stats.downloads_combined.daily,
1091            &stats.timeline_labels,
1092            RCLR_DOWNLOAD,
1093            Some(RCLR_DOWNLOAD_G),
1094            1.7,
1095            10,
1096        ));
1097        out.push('\n');
1098    }
1099    if stats.activity_score > 0 {
1100        out.push_str(">Combined Activity\n\n");
1101        out.push_str(&render_combined_chart(
1102            &stats.views.daily,
1103            &stats.fetches.daily,
1104            &stats.pushes.daily,
1105            &stats.downloads_combined.daily,
1106            &stats.timeline_labels,
1107            6,
1108        ));
1109    } else {
1110        out.push_str(
1111            "`*\nNo development activity recorded for this repository in the selected time period.\n\n`*",
1112        );
1113    }
1114    Ok(out)
1115}
1116
1117fn render_releases_page(
1118    config: &ServerConfig,
1119    access: &Access,
1120    remote: Option<&[u8; 16]>,
1121    vars: &BTreeMap<String, String>,
1122) -> Result<String> {
1123    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1124    let releases_path = crate::release::release_sidecar_path(&repository);
1125    let releases: Vec<_> = crate::release::list_releases(&releases_path)?
1126        .into_iter()
1127        .filter(|release| release.status == "published")
1128        .collect();
1129    let latest_release = crate::release::configured_latest_tag(&releases_path)?;
1130    let mut out = format!(
1131        ">>\n{} / {} / {} / releases\n\n>Releases ({})\n\n",
1132        m_link("Node", PATH_INDEX, &[]),
1133        m_link(&group, PATH_GROUP, &[("g", &group)]),
1134        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1135        releases.len()
1136    );
1137    if releases.is_empty() {
1138        out.push_str("No releases available for this repository.\n");
1139        return Ok(out);
1140    }
1141    for release in releases {
1142        let latest = if latest_release.as_deref() == Some(release.tag.as_str()) {
1143            format!(" {} `FT537855`*Latest`*`f", icon_sep(config))
1144        } else {
1145            String::new()
1146        };
1147        out.push_str(&format!(
1148            "{} `F666{} artifacts{}`f\n",
1149            m_link(
1150                &release.tag,
1151                PATH_RELEASE,
1152                &[("g", &group), ("r", &repo), ("tag", &release.tag)]
1153            ),
1154            release.artifacts,
1155            latest
1156        ));
1157        if !release.preview.is_empty() {
1158            append_release_preview(&mut out, &release.preview_format, &release.preview);
1159        }
1160        out.push('\n');
1161    }
1162    Ok(out)
1163}
1164
1165fn append_release_preview(out: &mut String, format: &str, preview: &str) {
1166    match format {
1167        "markdown" => out.push_str(&markdown_to_micron(preview)),
1168        "micron" => {
1169            out.push_str(preview);
1170            if !preview.ends_with('\n') {
1171                out.push('\n');
1172            }
1173        }
1174        _ => {
1175            out.push_str(&m_escape_line_start_controls(preview));
1176            out.push('\n');
1177        }
1178    }
1179}
1180
1181fn render_release_page(
1182    config: &ServerConfig,
1183    access: &Access,
1184    remote: Option<&[u8; 16]>,
1185    vars: &BTreeMap<String, String>,
1186) -> Result<String> {
1187    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1188    let releases_path = crate::release::release_sidecar_path(&repository);
1189    let requested_tag = var(vars, "tag").unwrap_or("latest");
1190    let tag = if requested_tag == "latest" {
1191        let Some(tag) = crate::release::latest_published_tag(&releases_path)? else {
1192            return Ok(">Release Not Found\n\nNo latest release exists.\n".to_string());
1193        };
1194        tag
1195    } else {
1196        requested_tag.to_string()
1197    };
1198    let Some(release) = crate::release::release_data(&releases_path, &tag)? else {
1199        return Ok(">Release Not Found\n\nThe requested release was not found.\n".to_string());
1200    };
1201    if release.status != "published" {
1202        return Ok(">Release Not Found\n\nThe requested release was not found.\n".to_string());
1203    }
1204    let release_dir = releases_path.join(&release.tag);
1205    let thanks = crate::release::release_thanks(&release_dir, var(vars, "thanks").is_some())?;
1206    let mut out = format!(
1207        ">>\n{} / {} / {} / {} / {}\n\n>Release {}\n\n",
1208        m_link("Node", PATH_INDEX, &[]),
1209        m_link(&group, PATH_GROUP, &[("g", &group)]),
1210        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1211        m_link("releases", PATH_RELEASES, &[("g", &group), ("r", &repo)]),
1212        m_escape(&release.tag),
1213        m_escape(&release.tag)
1214    );
1215    out.push_str(&format!(
1216        "{}\n\n",
1217        m_link(
1218            &icon_label(config, "heart", &format!("Thanks ({thanks})")),
1219            PATH_RELEASE,
1220            &[
1221                ("g", &group),
1222                ("r", &repo),
1223                ("tag", &release.tag),
1224                ("thanks", "y")
1225            ]
1226        )
1227    ));
1228    if !release.notes.is_empty() {
1229        match release.notes_format.as_str() {
1230            "markdown" => out.push_str(&markdown_to_micron(&release.notes)),
1231            "micron" => out.push_str(&release.notes),
1232            _ => out.push_str(&m_escape(&release.notes)),
1233        }
1234        if !out.ends_with('\n') {
1235            out.push('\n');
1236        }
1237        out.push('\n');
1238    }
1239    out.push_str(">Artifacts\n\n");
1240    if release.artifacts.is_empty() {
1241        out.push_str("`*No artifacts for this release`*\n");
1242    } else {
1243        for artifact in release.artifacts {
1244            out.push_str(&format!(
1245                "{} ({})\n",
1246                m_link(
1247                    &artifact.name,
1248                    PATH_DOWNLOAD,
1249                    &[
1250                        ("g", &group),
1251                        ("r", &repo),
1252                        ("tag", &release.tag),
1253                        ("artifact", &artifact.name)
1254                    ]
1255                ),
1256                format_size(artifact.size)
1257            ));
1258        }
1259    }
1260    Ok(out)
1261}
1262
1263fn render_work_page(
1264    config: &ServerConfig,
1265    access: &Access,
1266    remote: Option<&[u8; 16]>,
1267    vars: &BTreeMap<String, String>,
1268) -> Result<String> {
1269    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1270    let scope = var(vars, "scope").unwrap_or("active");
1271    let list_scope =
1272        crate::work::WorkListScope::parse(scope).ok_or_else(|| Error::msg("invalid scope"))?;
1273    let lists =
1274        crate::work::list_documents(&crate::work::work_sidecar_path(&repository), list_scope)?;
1275    let mut out = format!(
1276        ">>\n{} / {} / {} / work\n\n>Work Documents\n\n",
1277        m_link("Node", PATH_INDEX, &[]),
1278        m_link(&group, PATH_GROUP, &[("g", &group)]),
1279        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)])
1280    );
1281    let tabs = [
1282        m_link_raw(
1283            "Active",
1284            PATH_WORK,
1285            &[("g", &group), ("r", &repo), ("scope", "active")],
1286        ),
1287        m_link_raw(
1288            "Completed",
1289            PATH_WORK,
1290            &[("g", &group), ("r", &repo), ("scope", "completed")],
1291        ),
1292        m_link_raw(
1293            "Proposed",
1294            PATH_WORK,
1295            &[("g", &group), ("r", &repo), ("scope", "proposed")],
1296        ),
1297        m_link_raw(
1298            "All",
1299            PATH_WORK,
1300            &[("g", &group), ("r", &repo), ("scope", "all")],
1301        ),
1302    ];
1303    out.push_str(&format!(
1304        "{}\n\n",
1305        tabs.join(&format!(" {} ", icon_sep(config)))
1306    ));
1307    if matches!(
1308        list_scope,
1309        crate::work::WorkListScope::Active | crate::work::WorkListScope::All
1310    ) {
1311        append_work_section(
1312            &mut out,
1313            &group,
1314            &repo,
1315            crate::work::WorkScope::Active,
1316            &lists.active,
1317        );
1318    }
1319    if matches!(
1320        list_scope,
1321        crate::work::WorkListScope::Completed | crate::work::WorkListScope::All
1322    ) {
1323        append_work_section(
1324            &mut out,
1325            &group,
1326            &repo,
1327            crate::work::WorkScope::Completed,
1328            &lists.completed,
1329        );
1330    }
1331    if matches!(
1332        list_scope,
1333        crate::work::WorkListScope::Proposed | crate::work::WorkListScope::All
1334    ) {
1335        append_work_section(
1336            &mut out,
1337            &group,
1338            &repo,
1339            crate::work::WorkScope::Proposed,
1340            &lists.proposed,
1341        );
1342    }
1343    Ok(out)
1344}
1345
1346fn append_work_section(
1347    out: &mut String,
1348    group: &str,
1349    repo: &str,
1350    scope: crate::work::WorkScope,
1351    docs: &[crate::work::WorkSummary],
1352) {
1353    let title = match scope {
1354        crate::work::WorkScope::Active => "Active Work Documents",
1355        crate::work::WorkScope::Completed => "Completed Work Documents",
1356        crate::work::WorkScope::Proposed => "Proposed Work Documents",
1357    };
1358    out.push_str(&format!(">{title} ({})\n\n", docs.len()));
1359    if docs.is_empty() {
1360        out.push_str("No work documents found.\n\n");
1361        return;
1362    }
1363    for doc in docs {
1364        let doc_id = doc.id.to_string();
1365        out.push_str(&format!(
1366            "{} `F666{} updates`f\n",
1367            m_link(
1368                &format!("#{} {}", doc.id, doc.title),
1369                PATH_WORK_DOC,
1370                &[
1371                    ("g", group),
1372                    ("r", repo),
1373                    ("scope", scope.as_str()),
1374                    ("id", &doc_id)
1375                ]
1376            ),
1377            doc.comments
1378        ));
1379        out.push_str(&format!(
1380            "`F666{} by {}`f\n\n",
1381            format_unix_time(doc.created as i64),
1382            m_escape(&doc.author)
1383        ));
1384    }
1385}
1386
1387fn render_work_doc_page(
1388    config: &ServerConfig,
1389    access: &Access,
1390    remote: Option<&[u8; 16]>,
1391    vars: &BTreeMap<String, String>,
1392) -> Result<String> {
1393    let (group, repo, repository) = accessible_repository(config, access, remote, vars)?;
1394    let id = required_var(vars, "id")?
1395        .parse::<u64>()
1396        .map_err(|_| Error::msg("invalid work document ID"))?;
1397    let work_path = crate::work::work_sidecar_path(&repository);
1398    let (scope, document) =
1399        resolve_work_document(&work_path, var(vars, "scope").unwrap_or("all"), id)?;
1400    let Some(document) = document else {
1401        return Ok(
1402            ">Work Document Not Found\n\nThe requested work document was not found.\n".into(),
1403        );
1404    };
1405    let mut out = format!(
1406        ">>\n{} / {} / {} / {} / #{}\n\n>{}\n\n",
1407        m_link("Node", PATH_INDEX, &[]),
1408        m_link(&group, PATH_GROUP, &[("g", &group)]),
1409        m_link(&repo, PATH_REPO, &[("g", &group), ("r", &repo)]),
1410        m_link(
1411            "work",
1412            PATH_WORK,
1413            &[("g", &group), ("r", &repo), ("scope", scope.as_str())]
1414        ),
1415        document.id,
1416        m_escape(&document.title)
1417    );
1418    out.push_str(&format!(
1419        "`F666Status: {} | Author: {} | Signature: {} | Created: {} | Edited: {}`f\n\n",
1420        scope.as_str(),
1421        m_escape(&document.author),
1422        work_signature_status(&document),
1423        format_unix_time(document.created as i64),
1424        format_unix_time(document.edited as i64)
1425    ));
1426    let doc_id = document.id.to_string();
1427    out.push_str(&format!(
1428        "{}\n\n",
1429        m_link(
1430            "Download",
1431            PATH_WORK_DOC_DOWNLOAD,
1432            &[
1433                ("g", &group),
1434                ("r", &repo),
1435                ("scope", scope.as_str()),
1436                ("id", &doc_id)
1437            ]
1438        )
1439    ));
1440    append_formatted_content(&mut out, &document.format, &document.content);
1441    out.push_str(&format!("\n>Updates ({})\n\n", document.comments.len()));
1442    if document.comments.is_empty() {
1443        out.push_str("No updates for this work document.\n");
1444    } else {
1445        for comment in document.comments {
1446            out.push_str(&format!(
1447                ">#{} by {} at {}\n\n",
1448                comment.id,
1449                m_escape(&comment.author),
1450                format_unix_time(comment.created as i64)
1451            ));
1452            append_formatted_content(&mut out, &comment.format, &comment.content);
1453            out.push('\n');
1454        }
1455    }
1456    Ok(out)
1457}
1458
1459fn work_signature_status(document: &crate::work::WorkDocument) -> &'static str {
1460    let Some(signature) = document.signature.as_deref() else {
1461        return "Document not signed";
1462    };
1463    if signature.len() != 64 {
1464        return "Not valid";
1465    }
1466    let Ok(signature) = <&[u8; 64]>::try_from(signature) else {
1467        return "Not valid";
1468    };
1469    let Some(identity_bytes) = document.identity.as_deref() else {
1470        return "Not valid";
1471    };
1472    let Ok(public_key) = <[u8; 64]>::try_from(identity_bytes) else {
1473        return "Not valid";
1474    };
1475    let identity = Identity::from_public_key(&public_key);
1476    if identity.verify(signature, document.content.as_bytes()) {
1477        "Valid"
1478    } else {
1479        "Not valid"
1480    }
1481}
1482
1483fn resolve_work_document(
1484    work_path: &Path,
1485    requested_scope: &str,
1486    id: u64,
1487) -> Result<(crate::work::WorkScope, Option<crate::work::WorkDocument>)> {
1488    let requested_scope = if matches!(requested_scope, "active" | "completed" | "proposed" | "all")
1489    {
1490        requested_scope
1491    } else {
1492        "active"
1493    };
1494    match requested_scope {
1495        "active" | "completed" | "proposed" => {
1496            let scope = crate::work::WorkScope::parse(requested_scope).unwrap();
1497            Ok((scope, crate::work::view_document(work_path, scope, id)?))
1498        }
1499        "all" => {
1500            if let Some(document) =
1501                crate::work::view_document(work_path, crate::work::WorkScope::Active, id)?
1502            {
1503                Ok((crate::work::WorkScope::Active, Some(document)))
1504            } else if let Some(document) =
1505                crate::work::view_document(work_path, crate::work::WorkScope::Completed, id)?
1506            {
1507                Ok((crate::work::WorkScope::Completed, Some(document)))
1508            } else {
1509                Ok((
1510                    crate::work::WorkScope::Proposed,
1511                    crate::work::view_document(work_path, crate::work::WorkScope::Proposed, id)?,
1512                ))
1513            }
1514        }
1515        _ => unreachable!(),
1516    }
1517}
1518
1519fn append_formatted_content(out: &mut String, format: &str, content: &str) {
1520    match format {
1521        "micron" => out.push_str(content),
1522        "markdown" => out.push_str(&markdown_to_micron(content)),
1523        _ => out.push_str(&m_escape(content)),
1524    }
1525    if !out.ends_with('\n') {
1526        out.push('\n');
1527    }
1528}
1529
1530pub fn download_work_document(
1531    config: &ServerConfig,
1532    access: &Access,
1533    data: &[u8],
1534    remote: Option<&[u8; 16]>,
1535) -> Result<RequestResponse> {
1536    let vars = parse_page_vars(data)?;
1537    let (group, repo, repository) = match accessible_repository(config, access, remote, &vars) {
1538        Ok(repo) => repo,
1539        Err(_) => {
1540            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1541                protocol::RES_NOT_FOUND,
1542                b"repository not found",
1543            )));
1544        }
1545    };
1546    let id = required_var(&vars, "id")?
1547        .parse::<u64>()
1548        .map_err(|_| Error::msg("invalid work document ID"))?;
1549    let work_path = crate::work::work_sidecar_path(&repository);
1550    let Some(document) =
1551        resolve_work_document(&work_path, var(&vars, "scope").unwrap_or("all"), id)?.1
1552    else {
1553        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1554            protocol::RES_NOT_FOUND,
1555            b"work document not found",
1556        )));
1557    };
1558    crate::stats::record_download(config, &format!("{group}/{repo}"), remote);
1559    Ok(RequestResponse::Resource {
1560        data: document.content.into_bytes(),
1561        metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1562        auto_compress: true,
1563    })
1564}
1565
1566pub fn download_file(
1567    config: &ServerConfig,
1568    access: &Access,
1569    data: &[u8],
1570    remote: Option<&[u8; 16]>,
1571) -> Result<RequestResponse> {
1572    let vars = parse_page_vars(data)?;
1573    let (group, repo, repository) = match accessible_repository(config, access, remote, &vars) {
1574        Ok(repo) => repo,
1575        Err(_) => {
1576            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1577                protocol::RES_NOT_FOUND,
1578                b"repository not found",
1579            )));
1580        }
1581    };
1582    if let Some(artifact) = var(&vars, "artifact") {
1583        let artifact = decode_url_component(artifact);
1584        let tag = var(&vars, "tag").unwrap_or("latest");
1585        let releases_path = crate::release::release_sidecar_path(&repository);
1586        let Some(path) = crate::release::artifact_path(&releases_path, tag, &artifact)? else {
1587            return Ok(RequestResponse::Bytes(protocol::status_bytes(
1588                protocol::RES_NOT_FOUND,
1589                b"file not found",
1590            )));
1591        };
1592        let data = fs::read(path)?;
1593        crate::stats::record_release_download(config, &format!("{group}/{repo}"), remote);
1594        return Ok(RequestResponse::Resource {
1595            data,
1596            metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1597            auto_compress: true,
1598        });
1599    }
1600
1601    let reference = var(&vars, "ref").unwrap_or("HEAD");
1602    let path = required_var(&vars, "path")?;
1603    validate_git_path(path)?;
1604    let resolved = resolve_ref(&repository, reference)?;
1605    let spec = format!("{resolved}:{path}");
1606    let output = run_git_output(
1607        Command::new("git")
1608            .arg("--git-dir")
1609            .arg(&repository)
1610            .arg("show")
1611            .arg(spec),
1612        GIT_COMMAND_TIMEOUT,
1613    )?;
1614    if !output.status.success() {
1615        return Ok(RequestResponse::Bytes(protocol::status_bytes(
1616            protocol::RES_NOT_FOUND,
1617            b"file not found",
1618        )));
1619    }
1620    crate::stats::record_download(config, &format!("{group}/{repo}"), remote);
1621    Ok(RequestResponse::Resource {
1622        data: output.stdout,
1623        metadata: Some(protocol::metadata_status(protocol::RES_OK)),
1624        auto_compress: true,
1625    })
1626}
1627
1628fn render_chart(
1629    data: &[u64],
1630    labels: &[String; 2],
1631    color: &str,
1632    secondary_color: Option<&str>,
1633    gradient_factor: f64,
1634    height: u64,
1635) -> String {
1636    if data.is_empty() || data.iter().all(|value| *value == 0) {
1637        return "No data available\n".to_string();
1638    }
1639    let max = data.iter().copied().max().unwrap_or(1).max(1) as f64;
1640    let primary = expand_chart_color(color);
1641    let secondary = secondary_color
1642        .map(expand_chart_color)
1643        .unwrap_or_else(|| dim_chart_color(&primary));
1644    let mut out = format!("`FT{primary}Peak: {}`f\n", max as u64);
1645    for row in (1..=height).rev() {
1646        let row_top = row as f64 / height as f64 * max;
1647        let row_mid = (row as f64 - 0.5) / height as f64 * max;
1648        let grad_top = row as f64 / height as f64;
1649        let grad_mid = (row as f64 - 0.5) / height as f64;
1650        let top_color = gradient_chart_color(&primary, &secondary, grad_top, gradient_factor);
1651        let mid_color = gradient_chart_color(&primary, &secondary, grad_mid, gradient_factor);
1652        out.push('│');
1653        for value in data {
1654            let value = *value as f64;
1655            if value >= row_top {
1656                out.push_str(&format!("`FT{top_color}`BT{mid_color}▀`f`b"));
1657            } else if value >= row_mid || (row == 1 && value > 0.0) {
1658                out.push_str(&format!("`FT{mid_color}▄`f"));
1659            } else {
1660                out.push(' ');
1661            }
1662        }
1663        out.push('\n');
1664    }
1665    out.push('└');
1666    for _ in data {
1667        out.push('─');
1668    }
1669    out.push_str("┘\n");
1670    out.push_str(&format!("`F666{:<12}`f", labels[0]));
1671    let chart_width = data.len() + 2;
1672    let spacing = chart_width.saturating_sub(24);
1673    out.push_str(&" ".repeat(spacing));
1674    out.push_str(&format!("`F666{:>12}`f\n", labels[1]));
1675    out
1676}
1677
1678fn expand_chart_color(color: &str) -> String {
1679    let color = color.trim_start_matches('#');
1680    if color.len() == 3 {
1681        color.chars().flat_map(|ch| [ch, ch]).collect()
1682    } else {
1683        color.chars().take(6).collect()
1684    }
1685}
1686
1687fn dim_chart_color(primary: &str) -> String {
1688    let rgb = chart_rgb(primary);
1689    format!(
1690        "{:02x}{:02x}{:02x}",
1691        (rgb[0] as f64 * 0.42) as u8,
1692        (rgb[1] as f64 * 0.42) as u8,
1693        (rgb[2] as f64 * 0.42) as u8
1694    )
1695}
1696
1697fn gradient_chart_color(primary: &str, secondary: &str, t: f64, factor: f64) -> String {
1698    let primary = chart_rgb(primary);
1699    let secondary = chart_rgb(secondary);
1700    let t = (t * factor).min(1.0);
1701    format!(
1702        "{:02x}{:02x}{:02x}",
1703        (secondary[0] as f64 + (primary[0] as f64 - secondary[0] as f64) * t) as u8,
1704        (secondary[1] as f64 + (primary[1] as f64 - secondary[1] as f64) * t) as u8,
1705        (secondary[2] as f64 + (primary[2] as f64 - secondary[2] as f64) * t) as u8
1706    )
1707}
1708
1709fn chart_rgb(color: &str) -> [u8; 3] {
1710    let color = expand_chart_color(color);
1711    [
1712        u8::from_str_radix(color.get(0..2).unwrap_or("00"), 16).unwrap_or(0),
1713        u8::from_str_radix(color.get(2..4).unwrap_or("00"), 16).unwrap_or(0),
1714        u8::from_str_radix(color.get(4..6).unwrap_or("00"), 16).unwrap_or(0),
1715    ]
1716}
1717
1718fn render_combined_chart(
1719    views: &[u64],
1720    fetches: &[u64],
1721    pushes: &[u64],
1722    downloads: &[u64],
1723    labels: &[String; 2],
1724    height: u64,
1725) -> String {
1726    if views.is_empty() || fetches.is_empty() || pushes.is_empty() || downloads.is_empty() {
1727        return "No data available\n".to_string();
1728    }
1729
1730    let categories = [
1731        ("Pushes", dim_to_black(RCLR_PUSH, 0.87), pushes),
1732        ("Fetches", dim_to_black(RCLR_FETCH, 0.87), fetches),
1733        ("Views", dim_to_black(RCLR_VIEW, 0.87), views),
1734        ("Downloads", dim_to_black(RCLR_DOWNLOAD, 0.87), downloads),
1735    ];
1736    let mut out = categories
1737        .iter()
1738        .map(|(label, color, _)| format!("`FT{color}`BT{color}██`f`b {label}"))
1739        .collect::<Vec<_>>()
1740        .join("  ");
1741    out.push_str("\n\n");
1742    let num_points = views.len();
1743
1744    for row in (1..=height).rev() {
1745        let lower_min = (row as f64 - 1.0) / height as f64;
1746        let lower_max = (row as f64 - 0.5) / height as f64;
1747        let upper_min = (row as f64 - 0.5) / height as f64;
1748        let upper_max = row as f64 / height as f64;
1749        out.push('│');
1750        for index in 0..num_points {
1751            let total: u64 = categories
1752                .iter()
1753                .map(|(_, _, data)| data.get(index).copied().unwrap_or(0))
1754                .sum();
1755            if total == 0 {
1756                out.push(' ');
1757                continue;
1758            }
1759
1760            let upper_cat = stacked_pixel_category(&categories, index, total, upper_min, upper_max);
1761            let lower_cat = stacked_pixel_category(&categories, index, total, lower_min, lower_max);
1762            match (upper_cat, lower_cat) {
1763                (Some(upper), Some(lower)) if upper == lower => {
1764                    let color = &categories[upper].1;
1765                    out.push_str(&format!("`FT{color}`BT{color}█`f`b"));
1766                }
1767                (Some(upper), Some(lower)) => {
1768                    let upper_color = &categories[upper].1;
1769                    let lower_color = &categories[lower].1;
1770                    out.push_str(&format!("`FT{upper_color}`BT{lower_color}▀`f`b"));
1771                }
1772                (Some(upper), None) => {
1773                    let color = &categories[upper].1;
1774                    out.push_str(&format!("`FT{color}▀`f"));
1775                }
1776                (None, Some(lower)) => {
1777                    let color = &categories[lower].1;
1778                    out.push_str(&format!("`FT{color}▄`f"));
1779                }
1780                (None, None) => out.push(' '),
1781            }
1782        }
1783        out.push('\n');
1784    }
1785    out.push('└');
1786    for _ in 0..num_points {
1787        out.push('─');
1788    }
1789    out.push_str("┘\n");
1790    out.push_str(&format!(
1791        "`F666{:<12}`f",
1792        labels[0].chars().take(12).collect::<String>()
1793    ));
1794    let chart_width = num_points + 2;
1795    let spacing = chart_width.saturating_sub(24);
1796    out.push_str(&" ".repeat(spacing));
1797    out.push_str(&format!(
1798        "`F666{:>12}`f\n",
1799        labels[1].chars().take(12).collect::<String>()
1800    ));
1801    out
1802}
1803
1804fn stacked_pixel_category(
1805    categories: &[(&str, String, &[u64])],
1806    index: usize,
1807    total: u64,
1808    pixel_min: f64,
1809    pixel_max: f64,
1810) -> Option<usize> {
1811    let mut cumulative = 0u64;
1812    for (category_index, (_, _, data)) in categories.iter().enumerate() {
1813        let value = data.get(index).copied().unwrap_or(0);
1814        if value == 0 {
1815            continue;
1816        }
1817        let start = cumulative as f64 / total as f64;
1818        cumulative += value;
1819        let end = cumulative as f64 / total as f64;
1820        if pixel_min < end && pixel_max > start {
1821            return Some(category_index);
1822        }
1823    }
1824    None
1825}
1826
1827fn dim_to_black(color: &str, dim: f64) -> String {
1828    gradient_chart_color(&expand_chart_color(color), "000000", dim, 1.0)
1829}
1830
1831fn accessible_groups(
1832    config: &ServerConfig,
1833    access: &Access,
1834    remote: Option<&[u8; 16]>,
1835) -> Result<BTreeMap<String, Vec<String>>> {
1836    let mut groups = BTreeMap::new();
1837    if !config.repositories_dir.exists() {
1838        return Ok(groups);
1839    }
1840    for group_entry in fs::read_dir(&config.repositories_dir)? {
1841        let group_entry = group_entry?;
1842        if !group_entry.file_type()?.is_dir() {
1843            continue;
1844        }
1845        let group = group_entry.file_name().to_string_lossy().to_string();
1846        if validate_group_name(&group).is_err() {
1847            continue;
1848        }
1849        let repos = accessible_repositories(config, access, remote, &group)?;
1850        if !repos.is_empty() {
1851            groups.insert(group, repos);
1852        }
1853    }
1854    Ok(groups)
1855}
1856
1857fn accessible_repositories(
1858    config: &ServerConfig,
1859    access: &Access,
1860    remote: Option<&[u8; 16]>,
1861    group: &str,
1862) -> Result<Vec<String>> {
1863    validate_group_name(group)?;
1864    let group_dir = config.repositories_dir.join(group);
1865    let mut repos = Vec::new();
1866    if !group_dir.exists() {
1867        return Ok(repos);
1868    }
1869    for repo_entry in fs::read_dir(group_dir)? {
1870        let repo_entry = repo_entry?;
1871        if !repo_entry.file_type()?.is_dir() {
1872            continue;
1873        }
1874        let repo = repo_entry.file_name().to_string_lossy().to_string();
1875        let Ok(name) = repository_name(group, &repo) else {
1876            continue;
1877        };
1878        if crate::git::is_bare_repository(&config.repositories_dir.join(&name))
1879            && access.allows(Operation::Read, &name, remote)?
1880        {
1881            repos.push(repo);
1882        }
1883    }
1884    repos.sort();
1885    Ok(repos)
1886}
1887
1888fn accessible_repository(
1889    config: &ServerConfig,
1890    access: &Access,
1891    remote: Option<&[u8; 16]>,
1892    vars: &BTreeMap<String, String>,
1893) -> Result<(String, String, PathBuf)> {
1894    let group = required_var(vars, "g")?;
1895    let repo = required_var(vars, "r")?;
1896    let name = repository_name(group, repo)?;
1897    let path = config.repositories_dir.join(&name);
1898    if !crate::git::is_bare_repository(&path) || !access.allows(Operation::Read, &name, remote)? {
1899        return Err(Error::msg("repository not found"));
1900    }
1901    Ok((group.to_string(), repo.to_string(), path))
1902}
1903
1904fn required_var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Result<&'a str> {
1905    var(vars, key).ok_or_else(|| Error::msg(format!("missing page variable {key}")))
1906}
1907
1908fn decode_url_component(value: &str) -> String {
1909    let bytes = value.as_bytes();
1910    let mut out = Vec::with_capacity(bytes.len());
1911    let mut i = 0;
1912    while i < bytes.len() {
1913        match bytes[i] {
1914            b'+' => {
1915                out.push(b' ');
1916                i += 1;
1917            }
1918            b'%' if i + 2 < bytes.len() => {
1919                if let (Some(high), Some(low)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2]))
1920                {
1921                    out.push((high << 4) | low);
1922                    i += 3;
1923                } else {
1924                    out.push(bytes[i]);
1925                    i += 1;
1926                }
1927            }
1928            byte => {
1929                out.push(byte);
1930                i += 1;
1931            }
1932        }
1933    }
1934    String::from_utf8_lossy(&out).into_owned()
1935}
1936
1937fn hex_value(byte: u8) -> Option<u8> {
1938    match byte {
1939        b'0'..=b'9' => Some(byte - b'0'),
1940        b'a'..=b'f' => Some(byte - b'a' + 10),
1941        b'A'..=b'F' => Some(byte - b'A' + 10),
1942        _ => None,
1943    }
1944}
1945
1946fn var<'a>(vars: &'a BTreeMap<String, String>, key: &str) -> Option<&'a str> {
1947    vars.get(&format!("var_{key}"))
1948        .or_else(|| vars.get(key))
1949        .map(String::as_str)
1950}
1951
1952fn repository_name(group: &str, repo: &str) -> Result<String> {
1953    validate_group_name(group)?;
1954    validate_repo_component(repo)?;
1955    let name = format!("{group}/{repo}");
1956    validate_repo_name(&name)?;
1957    Ok(name)
1958}
1959
1960fn validate_group_name(group: &str) -> Result<()> {
1961    validate_repo_component(group)
1962}
1963
1964fn validate_repo_component(component: &str) -> Result<()> {
1965    if component.is_empty()
1966        || component == "."
1967        || component == ".."
1968        || component.contains('/')
1969        || component.contains('\\')
1970        || component.len() > 256
1971    {
1972        Err(Error::msg("invalid repository name"))
1973    } else {
1974        Ok(())
1975    }
1976}
1977
1978fn validate_git_path(path: &str) -> Result<()> {
1979    if path.starts_with('/') || path.contains('\\') {
1980        return Err(Error::msg("invalid repository path"));
1981    }
1982    for component in path.split('/') {
1983        if component == "." || component == ".." {
1984            return Err(Error::msg("invalid repository path"));
1985        }
1986    }
1987    Ok(())
1988}
1989
1990fn normalize_blob_path(path: &str) -> String {
1991    path.strip_prefix("./").unwrap_or(path).replace("/./", "/")
1992}
1993
1994fn validate_refish(value: &str) -> Result<()> {
1995    if value.is_empty()
1996        || value.starts_with('-')
1997        || value.contains("..")
1998        || value.contains('\\')
1999        || value.contains(' ')
2000        || value.contains('~')
2001        || value.contains('^')
2002        || value.contains(':')
2003    {
2004        Err(Error::msg("invalid git ref"))
2005    } else {
2006        Ok(())
2007    }
2008}
2009
2010fn repository_description(repo: &Path) -> Result<String> {
2011    let path = repo.join("description");
2012    if !path.exists() {
2013        return Ok(String::new());
2014    }
2015    Ok(fs::read_to_string(path)?.trim().to_string())
2016}
2017
2018#[derive(Debug, Clone, PartialEq, Eq)]
2019struct RepositoryOrigin {
2020    kind: RepositoryOriginKind,
2021    source: String,
2022    synced_at: Option<u64>,
2023}
2024
2025#[derive(Debug, Clone, PartialEq, Eq)]
2026enum RepositoryOriginKind {
2027    Fork,
2028    Mirror,
2029}
2030
2031impl RepositoryOrigin {
2032    fn label(&self) -> &'static str {
2033        match self.kind {
2034            RepositoryOriginKind::Fork => "Forked",
2035            RepositoryOriginKind::Mirror => "Mirrored",
2036        }
2037    }
2038}
2039
2040fn repository_origin(repo: &Path) -> Result<Option<RepositoryOrigin>> {
2041    let kind_output = run_git_output(
2042        Command::new("git")
2043            .arg("--git-dir")
2044            .arg(repo)
2045            .arg("config")
2046            .arg("--get")
2047            .arg("repository.rngit.type"),
2048        GIT_COMMAND_TIMEOUT,
2049    )?;
2050    if !kind_output.status.success() {
2051        return Ok(None);
2052    }
2053    let kind = match String::from_utf8_lossy(&kind_output.stdout).trim() {
2054        "fork" => RepositoryOriginKind::Fork,
2055        "mirror" => RepositoryOriginKind::Mirror,
2056        _ => return Ok(None),
2057    };
2058    let source_output = run_git_output(
2059        Command::new("git")
2060            .arg("--git-dir")
2061            .arg(repo)
2062            .arg("config")
2063            .arg("--get")
2064            .arg("repository.rngit.upstream.source"),
2065        GIT_COMMAND_TIMEOUT,
2066    )?;
2067    if !source_output.status.success() {
2068        return Ok(None);
2069    }
2070    let source = String::from_utf8_lossy(&source_output.stdout)
2071        .trim()
2072        .to_string();
2073    if source.is_empty() {
2074        return Ok(None);
2075    }
2076    let synced_at = run_git_output(
2077        Command::new("git")
2078            .arg("--git-dir")
2079            .arg(repo)
2080            .arg("config")
2081            .arg("--get")
2082            .arg("repository.rngit.upstream.sync"),
2083        GIT_COMMAND_TIMEOUT,
2084    )
2085    .ok()
2086    .and_then(|output| output.status.success().then_some(output))
2087    .and_then(|output| {
2088        String::from_utf8_lossy(&output.stdout)
2089            .trim()
2090            .parse::<u64>()
2091            .ok()
2092    });
2093
2094    Ok(Some(RepositoryOrigin {
2095        kind,
2096        source,
2097        synced_at,
2098    }))
2099}
2100
2101fn format_sync_age(synced_at: u64) -> String {
2102    let now = SystemTime::now()
2103        .duration_since(UNIX_EPOCH)
2104        .unwrap_or_default()
2105        .as_secs();
2106    let seconds = now.saturating_sub(synced_at);
2107    if seconds < 60 {
2108        format!("{seconds}s")
2109    } else if seconds < 3_600 {
2110        format!("{}m", seconds / 60)
2111    } else if seconds < 86_400 {
2112        format!("{}h", seconds / 3_600)
2113    } else {
2114        format!("{}d", seconds / 86_400)
2115    }
2116}
2117
2118fn repository_thanks(repo: &Path, add: bool) -> Result<u64> {
2119    let path = repo.with_extension("thanks");
2120    let mut count = if path.exists() {
2121        fs::read_to_string(&path)?
2122            .trim()
2123            .parse::<u64>()
2124            .unwrap_or(0)
2125    } else {
2126        0
2127    };
2128    if add {
2129        count = count.saturating_add(1);
2130        fs::write(path, format!("{count}\n"))?;
2131    }
2132    Ok(count)
2133}
2134
2135#[derive(Debug)]
2136struct ReadmeContent {
2137    content: String,
2138    markdown: bool,
2139}
2140
2141fn readme_content(repo: &Path) -> Result<Option<ReadmeContent>> {
2142    const NAMES: &[&str] = &[
2143        "README.mu",
2144        "Readme.mu",
2145        "readme.mu",
2146        "README",
2147        "readme",
2148        "README.md",
2149        "readme.md",
2150        "README.rst",
2151        "README.txt",
2152        "readme.rst",
2153        "readme.txt",
2154    ];
2155    for name in NAMES {
2156        match run_git_output(
2157            Command::new("git")
2158                .arg("--git-dir")
2159                .arg(repo)
2160                .arg("show")
2161                .arg(format!("HEAD:{name}")),
2162            GIT_COMMAND_TIMEOUT,
2163        ) {
2164            Ok(output) if output.status.success() => {
2165                let content = String::from_utf8_lossy(&output.stdout).into_owned();
2166                let markdown = name.ends_with(".md");
2167                let content = if markdown || name.ends_with(".mu") {
2168                    content
2169                } else {
2170                    m_escape(&content)
2171                };
2172                return Ok(Some(ReadmeContent { content, markdown }));
2173            }
2174            Ok(_) => {}
2175            Err(err) => {
2176                log::warn!("Git command execution failed while reading README: {err}");
2177            }
2178        }
2179    }
2180    Ok(None)
2181}
2182
2183fn markdown_to_micron(input: &str) -> String {
2184    markdown_to_micron_scoped(input, None)
2185}
2186
2187fn markdown_to_micron_scoped(input: &str, url_scope: Option<&str>) -> String {
2188    let lines: Vec<&str> = input.lines().collect();
2189    let mut out = String::new();
2190    let mut code_block: Option<(Option<String>, String)> = None;
2191    let mut index = 0;
2192
2193    while index < lines.len() {
2194        let line = lines[index];
2195        let trimmed = line.trim();
2196        if trimmed.starts_with("```") {
2197            if let Some((language, content)) = code_block.take() {
2198                out.push_str(&markdown_code_block_to_micron(
2199                    language.as_deref(),
2200                    &content,
2201                ));
2202            } else {
2203                code_block = Some((markdown_fence_language(trimmed), String::new()));
2204            }
2205            index += 1;
2206            continue;
2207        }
2208        if let Some((_, content)) = code_block.as_mut() {
2209            content.push_str(line);
2210            content.push('\n');
2211            index += 1;
2212            continue;
2213        }
2214        if let Some(quote) = markdown_quote(line) {
2215            let mut parts = vec![quote.to_string()];
2216            index += 1;
2217            while index < lines.len() {
2218                if let Some(quote) = markdown_quote(lines[index]) {
2219                    parts.push(quote.to_string());
2220                    index += 1;
2221                } else {
2222                    break;
2223                }
2224            }
2225            let formatted = format_markdown_inline_scoped(&parts.join(" "), url_scope);
2226            for wrapped in markdown_wrap_text(&formatted, 77) {
2227                out.push_str(" │ ");
2228                out.push_str(&wrapped);
2229                out.push('\n');
2230            }
2231            continue;
2232        }
2233        if is_table_start(&lines, index) {
2234            let mut table = Vec::new();
2235            while index < lines.len() && lines[index].contains('|') {
2236                table.push(lines[index]);
2237                index += 1;
2238            }
2239            out.push_str(&format_markdown_table(&table, url_scope));
2240            continue;
2241        }
2242        if let Some((level, text)) = markdown_heading(line) {
2243            out.push_str(&">".repeat(level));
2244            out.push_str(&format_markdown_inline_scoped(text.trim(), url_scope));
2245            out.push('\n');
2246        } else if is_markdown_rule(trimmed) {
2247            out.push_str("-\n");
2248        } else if let Some((indent, text)) = unordered_list_item(line) {
2249            out.push_str(indent);
2250            out.push_str(" • ");
2251            out.push_str(&format_markdown_inline_scoped(text, url_scope));
2252            out.push('\n');
2253        } else if let Some((indent, number, text)) = ordered_list_item(line) {
2254            out.push_str(indent);
2255            out.push_str(number);
2256            out.push_str(". ");
2257            out.push_str(&format_markdown_inline_scoped(text, url_scope));
2258            out.push('\n');
2259        } else {
2260            out.push_str(&format_markdown_line(line, url_scope));
2261            out.push('\n');
2262        }
2263        index += 1;
2264    }
2265    if let Some((language, content)) = code_block {
2266        out.push_str(&markdown_code_block_to_micron(
2267            language.as_deref(),
2268            &content,
2269        ));
2270    }
2271
2272    out
2273}
2274
2275fn markdown_code_block_to_micron(language: Option<&str>, content: &str) -> String {
2276    if language.is_some_and(|language| language.eq_ignore_ascii_case("rawmu")) {
2277        return content.to_string();
2278    }
2279    crate::highlight::literal_block(content, None, language)
2280}
2281
2282fn markdown_fence_language(trimmed: &str) -> Option<String> {
2283    trimmed
2284        .strip_prefix("```")
2285        .and_then(|info| info.split_whitespace().next())
2286        .filter(|language| !language.is_empty())
2287        .map(str::to_string)
2288}
2289
2290fn markdown_quote(line: &str) -> Option<&str> {
2291    line.strip_prefix('>')
2292        .map(|quote| quote.strip_prefix(' ').unwrap_or(quote))
2293}
2294
2295fn markdown_heading(line: &str) -> Option<(usize, &str)> {
2296    let level = line.chars().take_while(|ch| *ch == '#').count();
2297    if (1..=6).contains(&level) && line.as_bytes().get(level) == Some(&b' ') {
2298        Some((level, &line[level + 1..]))
2299    } else {
2300        None
2301    }
2302}
2303
2304fn is_markdown_rule(trimmed: &str) -> bool {
2305    trimmed.len() >= 3
2306        && ["-", "*", "_", "="]
2307            .iter()
2308            .any(|marker| trimmed.chars().all(|ch| ch.to_string() == *marker))
2309}
2310
2311fn unordered_list_item(line: &str) -> Option<(&str, &str)> {
2312    let trimmed = line.trim_start();
2313    let indent = &line[..line.len() - trimmed.len()];
2314    for marker in ["- ", "* ", "+ "] {
2315        if let Some(text) = trimmed.strip_prefix(marker) {
2316            return Some((indent, text));
2317        }
2318    }
2319    None
2320}
2321
2322fn ordered_list_item(line: &str) -> Option<(&str, &str, &str)> {
2323    let trimmed = line.trim_start();
2324    let indent = &line[..line.len() - trimmed.len()];
2325    let (number, text) = trimmed.split_once(". ")?;
2326    if !number.is_empty() && number.chars().all(|ch| ch.is_ascii_digit()) {
2327        Some((indent, number, text))
2328    } else {
2329        None
2330    }
2331}
2332
2333fn format_markdown_line(line: &str, url_scope: Option<&str>) -> String {
2334    let formatted = format_markdown_inline_scoped(line, url_scope);
2335    if (line.starts_with('-') && !line.starts_with("---") && !line.starts_with("- "))
2336        || line.starts_with('<')
2337    {
2338        format!("\\{formatted}")
2339    } else {
2340        formatted
2341    }
2342}
2343
2344fn is_table_start(lines: &[&str], index: usize) -> bool {
2345    index + 1 < lines.len()
2346        && lines[index].contains('|')
2347        && lines[index + 1].contains('|')
2348        && is_table_separator(lines[index + 1])
2349}
2350
2351fn is_table_separator(line: &str) -> bool {
2352    parse_table_row(line)
2353        .iter()
2354        .all(|cell| !cell.is_empty() && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
2355}
2356
2357#[derive(Clone, Copy)]
2358enum TableAlign {
2359    Left,
2360    Right,
2361    Center,
2362}
2363
2364fn format_markdown_table(lines: &[&str], url_scope: Option<&str>) -> String {
2365    if lines.len() < 2 {
2366        return String::new();
2367    }
2368    let header: Vec<String> = parse_table_row(lines[0])
2369        .into_iter()
2370        .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2371        .collect();
2372    if header.is_empty() {
2373        return String::new();
2374    }
2375    let columns = header.len();
2376    let mut alignments = parse_table_alignments(lines[1]);
2377    while alignments.len() < columns {
2378        alignments.push(TableAlign::Left);
2379    }
2380    alignments.truncate(columns);
2381
2382    let rows: Vec<Vec<String>> = lines
2383        .iter()
2384        .skip(2)
2385        .map(|line| {
2386            let mut row: Vec<String> = parse_table_row(line)
2387                .into_iter()
2388                .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2389                .collect();
2390            while row.len() < columns {
2391                row.push(String::new());
2392            }
2393            row.truncate(columns);
2394            row
2395        })
2396        .collect();
2397
2398    let mut widths = vec![3; columns];
2399    for (index, cell) in header.iter().enumerate() {
2400        widths[index] = widths[index].max(markdown_visible_width(cell));
2401    }
2402    for row in &rows {
2403        for (index, cell) in row.iter().enumerate().take(columns) {
2404            widths[index] = widths[index].max(markdown_visible_width(cell));
2405        }
2406    }
2407
2408    let mut out = String::new();
2409    out.push_str(&table_border('┌', '┬', '┐', &widths));
2410    out.push_str(&table_row(
2411        &header,
2412        &widths,
2413        &vec![TableAlign::Left; columns],
2414    ));
2415    out.push_str(&table_border('├', '┼', '┤', &widths));
2416    for row in rows {
2417        out.push_str(&table_row(&row, &widths, &alignments));
2418    }
2419    out.push_str(&table_border('└', '┴', '┘', &widths));
2420    out
2421}
2422
2423fn parse_table_row(line: &str) -> Vec<String> {
2424    let mut trimmed = line.trim();
2425    if let Some(rest) = trimmed.strip_prefix('|') {
2426        trimmed = rest;
2427    }
2428    if let Some(rest) = trimmed.strip_suffix('|') {
2429        trimmed = rest;
2430    }
2431    let mut cells = Vec::new();
2432    let mut current = String::new();
2433    let mut escaped = false;
2434    for ch in trimmed.chars() {
2435        if escaped {
2436            current.push(ch);
2437            escaped = false;
2438        } else if ch == '\\' {
2439            escaped = true;
2440        } else if ch == '|' {
2441            cells.push(current.trim().to_string());
2442            current.clear();
2443        } else {
2444            current.push(ch);
2445        }
2446    }
2447    if escaped {
2448        current.push('\\');
2449    }
2450    cells.push(current.trim().to_string());
2451    cells
2452}
2453
2454fn parse_table_alignments(line: &str) -> Vec<TableAlign> {
2455    parse_table_row(line)
2456        .into_iter()
2457        .map(|cell| {
2458            let cell = cell.trim();
2459            if cell.starts_with(':') && cell.ends_with(':') {
2460                TableAlign::Center
2461            } else if cell.ends_with(':') {
2462                TableAlign::Right
2463            } else {
2464                TableAlign::Left
2465            }
2466        })
2467        .collect()
2468}
2469
2470fn table_border(left: char, middle: char, right: char, widths: &[usize]) -> String {
2471    let mut out = String::new();
2472    out.push(left);
2473    for (index, width) in widths.iter().enumerate() {
2474        out.push_str(&"─".repeat(width + 2));
2475        if index + 1 == widths.len() {
2476            out.push(right);
2477        } else {
2478            out.push(middle);
2479        }
2480    }
2481    out.push('\n');
2482    out
2483}
2484
2485fn table_row(cells: &[String], widths: &[usize], alignments: &[TableAlign]) -> String {
2486    let mut out = String::from("│");
2487    for (index, width) in widths.iter().enumerate() {
2488        let cell = cells.get(index).map(String::as_str).unwrap_or("");
2489        out.push(' ');
2490        out.push_str(&pad_table_cell(
2491            cell,
2492            *width,
2493            alignments.get(index).copied().unwrap_or(TableAlign::Left),
2494        ));
2495        out.push(' ');
2496        out.push('│');
2497    }
2498    out.push('\n');
2499    out
2500}
2501
2502fn pad_table_cell(cell: &str, width: usize, alignment: TableAlign) -> String {
2503    let visible = markdown_visible_width(cell);
2504    let padding = width.saturating_sub(visible);
2505    match alignment {
2506        TableAlign::Left => format!("{cell}{}", " ".repeat(padding)),
2507        TableAlign::Right => format!("{}{cell}", " ".repeat(padding)),
2508        TableAlign::Center => {
2509            let left = padding / 2;
2510            let right = padding - left;
2511            format!("{}{cell}{}", " ".repeat(left), " ".repeat(right))
2512        }
2513    }
2514}
2515
2516fn markdown_visible_width(value: &str) -> usize {
2517    let mut width = 0;
2518    let mut chars = value.chars().peekable();
2519    while let Some(ch) = chars.next() {
2520        if ch == '`' {
2521            if matches!(chars.peek(), Some('F') | Some('B')) {
2522                chars.next();
2523                if matches!(chars.peek(), Some('T')) {
2524                    chars.next();
2525                    for _ in 0..6 {
2526                        chars.next();
2527                    }
2528                } else {
2529                    for _ in 0..3 {
2530                        chars.next();
2531                    }
2532                }
2533                continue;
2534            }
2535            if matches!(chars.peek(), Some('!') | Some('*') | Some('_') | Some('=')) {
2536                chars.next();
2537                continue;
2538            }
2539            if matches!(chars.peek(), Some('f') | Some('b') | Some('a')) {
2540                chars.next();
2541                continue;
2542            }
2543            if matches!(chars.peek(), Some('[')) {
2544                let mut label_width = 0;
2545                for link_ch in chars.by_ref() {
2546                    if link_ch == '`' {
2547                        for target_ch in chars.by_ref() {
2548                            if target_ch == ']' {
2549                                break;
2550                            }
2551                        }
2552                        break;
2553                    }
2554                    label_width += 1;
2555                }
2556                width += label_width;
2557                continue;
2558            }
2559        }
2560        width += 1;
2561    }
2562    width
2563}
2564
2565fn markdown_wrap_text(text: &str, width: usize) -> Vec<String> {
2566    if text.is_empty() {
2567        return vec![String::new()];
2568    }
2569    let mut lines = Vec::new();
2570    let mut current = String::new();
2571    let mut current_width = 0;
2572    for word in text.split_whitespace() {
2573        let word_width = markdown_visible_width(word);
2574        if !current.is_empty() && current_width + 1 + word_width > width {
2575            lines.push(current);
2576            current = String::new();
2577            current_width = 0;
2578        }
2579        if !current.is_empty() {
2580            current.push(' ');
2581            current_width += 1;
2582        }
2583        current.push_str(word);
2584        current_width += word_width;
2585    }
2586    if !current.is_empty() {
2587        lines.push(current);
2588    }
2589    if lines.is_empty() {
2590        lines.push(String::new());
2591    }
2592    lines
2593}
2594
2595#[derive(Debug)]
2596enum InlineToken {
2597    Link { label: String, target: String },
2598    Code(String),
2599}
2600
2601#[derive(Debug, Clone, Copy)]
2602struct MarkdownLinkStyle<'a> {
2603    bold: bool,
2604    underline: bool,
2605    color: Option<&'a str>,
2606}
2607
2608impl Default for MarkdownLinkStyle<'_> {
2609    fn default() -> Self {
2610        Self {
2611            bold: true,
2612            underline: true,
2613            color: None,
2614        }
2615    }
2616}
2617
2618fn format_markdown_inline_scoped(input: &str, url_scope: Option<&str>) -> String {
2619    let (with_placeholders, tokens) = extract_markdown_inline_tokens(input);
2620    let escaped = m_escape(&with_placeholders);
2621    let styled = apply_markdown_style(&escaped, "**", "`!", "`!");
2622    let styled = apply_markdown_style(&styled, "__", "`!", "`!");
2623    let styled = apply_markdown_style(&styled, "*", "`*", "`*");
2624    let styled = apply_markdown_style(&styled, "_", "`*", "`*");
2625    restore_markdown_inline_tokens(&styled, &tokens, url_scope)
2626}
2627
2628fn extract_markdown_inline_tokens(input: &str) -> (String, Vec<InlineToken>) {
2629    let mut out = String::new();
2630    let mut tokens = Vec::new();
2631    let mut index = 0;
2632
2633    while index < input.len() {
2634        let rest = &input[index..];
2635        if let Some(stripped) = rest.strip_prefix('`') {
2636            if let Some(end) = stripped.find('`') {
2637                let content = &stripped[..end];
2638                if is_markdown_inline_code(content) {
2639                    tokens.push(InlineToken::Code(content.to_string()));
2640                    out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2641                    index += end + 2;
2642                    continue;
2643                }
2644            }
2645        }
2646        if rest.starts_with('[') {
2647            if let Some((consumed, label, target)) = parse_markdown_link(rest) {
2648                tokens.push(InlineToken::Link { label, target });
2649                out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2650                index += consumed;
2651                continue;
2652            }
2653        }
2654        let Some(ch) = rest.chars().next() else {
2655            break;
2656        };
2657        out.push(ch);
2658        index += ch.len_utf8();
2659    }
2660
2661    (out, tokens)
2662}
2663
2664fn is_markdown_inline_code(content: &str) -> bool {
2665    !content.is_empty()
2666        && !content
2667            .chars()
2668            .next()
2669            .is_some_and(|ch| matches!(ch, '!' | '*' | '_' | '=' | 'F' | 'B' | 'f' | 'b' | 'a'))
2670}
2671
2672fn parse_markdown_link(input: &str) -> Option<(usize, String, String)> {
2673    let label_end = input[1..].find(']')? + 1;
2674    if !input[label_end + 1..].starts_with('(') {
2675        return None;
2676    }
2677    let target_start = label_end + 2;
2678    let target_end = input[target_start..].find(')')? + target_start;
2679    Some((
2680        target_end + 1,
2681        input[1..label_end].replace('`', ""),
2682        input[target_start..target_end].to_string(),
2683    ))
2684}
2685
2686fn markdown_token_placeholder(index: usize) -> String {
2687    format!("\u{1f}{index}\u{1f}")
2688}
2689
2690fn restore_markdown_inline_tokens(
2691    input: &str,
2692    tokens: &[InlineToken],
2693    url_scope: Option<&str>,
2694) -> String {
2695    let mut out = input.to_string();
2696    for (index, token) in tokens.iter().enumerate() {
2697        let replacement = match token {
2698            InlineToken::Link { label, target } => {
2699                format_markdown_link(label, target, url_scope, MarkdownLinkStyle::default())
2700            }
2701            InlineToken::Code(value) => format!("`BT383838`Fddd{}`f`b", m_escape(value)),
2702        };
2703        out = out.replace(&markdown_token_placeholder(index), &replacement);
2704    }
2705    out
2706}
2707
2708fn format_markdown_link(
2709    label: &str,
2710    target: &str,
2711    url_scope: Option<&str>,
2712    style: MarkdownLinkStyle<'_>,
2713) -> String {
2714    let mut link = String::new();
2715    if style.underline {
2716        link.push_str("`_");
2717    }
2718    if style.bold {
2719        link.push_str("`!");
2720    }
2721    link.push_str("`[");
2722    link.push_str(sanitize_label(label).trim());
2723    link.push('`');
2724    link.push_str(&markdown_link_target(target, url_scope));
2725    link.push(']');
2726    if style.bold {
2727        link.push_str("`!");
2728    }
2729    if style.underline {
2730        link.push_str("`_");
2731    }
2732
2733    match style.color {
2734        Some(color) if color.len() == 3 => format!("`F{color}{link}`f"),
2735        Some(color) if color.len() == 6 => format!("`FT{color}{link}`f"),
2736        _ => link,
2737    }
2738}
2739
2740fn markdown_link_target(value: &str, url_scope: Option<&str>) -> String {
2741    if let Some(scope) = url_scope {
2742        if !value.contains(":/") {
2743            let (path, anchor) = value
2744                .split_once('#')
2745                .map(|(path, anchor)| (path, Some(anchor)))
2746                .unwrap_or((value, None));
2747            let mut out = format!("{scope}{}", sanitize_field(path));
2748            if let Some(anchor) = anchor.filter(|anchor| !anchor.is_empty()) {
2749                out.push_str("|anchor=");
2750                out.push_str(&sanitize_field(anchor));
2751            }
2752            return out;
2753        }
2754    }
2755    sanitize_markdown_link_target(value)
2756}
2757
2758fn sanitize_markdown_link_target(value: &str) -> String {
2759    value
2760        .chars()
2761        .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
2762        .collect()
2763}
2764
2765fn apply_markdown_style(input: &str, marker: &str, open: &str, close: &str) -> String {
2766    let mut out = String::new();
2767    let mut rest = input;
2768
2769    while let Some(start) = rest.find(marker) {
2770        let content_start = start + marker.len();
2771        let Some(end) = rest[content_start..].find(marker) else {
2772            out.push_str(rest);
2773            return out;
2774        };
2775        let content_end = content_start + end;
2776        out.push_str(&rest[..start]);
2777        out.push_str(open);
2778        out.push_str(&rest[content_start..content_end]);
2779        out.push_str(close);
2780        rest = &rest[content_end + marker.len()..];
2781    }
2782    out.push_str(rest);
2783    out
2784}
2785
2786#[derive(Debug)]
2787struct Refs {
2788    heads: Vec<RefInfo>,
2789    tags: Vec<RefInfo>,
2790}
2791
2792#[derive(Debug)]
2793struct RefInfo {
2794    sha: String,
2795    name: String,
2796}
2797
2798fn git_refs(repo: &Path) -> Result<Refs> {
2799    let output = run_git(
2800        Command::new("git")
2801            .arg("--git-dir")
2802            .arg(repo)
2803            .args(["for-each-ref", "--format=%(objectname) %(refname)"]),
2804    )?;
2805    let mut refs = Refs {
2806        heads: Vec::new(),
2807        tags: Vec::new(),
2808    };
2809    for line in output.lines() {
2810        let Some((sha, name)) = line.split_once(' ') else {
2811            continue;
2812        };
2813        if let Some(short) = name.strip_prefix("refs/heads/") {
2814            refs.heads.push(RefInfo {
2815                sha: sha.to_string(),
2816                name: short.to_string(),
2817            });
2818        } else if let Some(short) = name.strip_prefix("refs/tags/") {
2819            refs.tags.push(RefInfo {
2820                sha: sha.to_string(),
2821                name: short.to_string(),
2822            });
2823        }
2824    }
2825    Ok(refs)
2826}
2827
2828fn resolve_ref(repo: &Path, reference: &str) -> Result<String> {
2829    validate_refish(reference)?;
2830    let output = run_git(
2831        Command::new("git")
2832            .arg("--git-dir")
2833            .arg(repo)
2834            .arg("rev-parse")
2835            .arg("--verify")
2836            .arg(format!("{reference}^{{commit}}")),
2837    )?;
2838    Ok(output.trim().to_string())
2839}
2840
2841#[derive(Debug)]
2842struct TreeEntry {
2843    kind: String,
2844    name: String,
2845    size: Option<u64>,
2846}
2847
2848impl TreeEntry {
2849    fn size_label(&self) -> String {
2850        self.size
2851            .map(|size| format!(" ({size} bytes)"))
2852            .unwrap_or_default()
2853    }
2854}
2855
2856fn tree_entries(repo: &Path, reference: &str, path: &str) -> Result<Vec<TreeEntry>> {
2857    validate_git_path(path)?;
2858    let spec = if path.is_empty() {
2859        reference.to_string()
2860    } else {
2861        format!("{reference}:{path}")
2862    };
2863    let output = run_git(
2864        Command::new("git")
2865            .arg("--git-dir")
2866            .arg(repo)
2867            .arg("ls-tree")
2868            .arg("-l")
2869            .arg(spec),
2870    )?;
2871    let mut entries = Vec::new();
2872    for line in output.lines() {
2873        let Some((meta, name)) = line.split_once('\t') else {
2874            continue;
2875        };
2876        let parts: Vec<&str> = meta.split_whitespace().collect();
2877        if parts.len() < 4 {
2878            continue;
2879        }
2880        let size = parts[3].parse::<u64>().ok();
2881        entries.push(TreeEntry {
2882            kind: parts[1].to_string(),
2883            name: name.to_string(),
2884            size,
2885        });
2886    }
2887    entries.sort_by_key(|entry| (entry.kind != "tree", entry.name.to_ascii_lowercase()));
2888    Ok(entries)
2889}
2890
2891fn git_object_kind(repo: &Path, reference: &str, path: &str) -> Result<String> {
2892    validate_git_path(path)?;
2893    let spec = if path.is_empty() {
2894        reference.to_string()
2895    } else {
2896        format!("{reference}:{path}")
2897    };
2898    let output = run_git(
2899        Command::new("git")
2900            .arg("--git-dir")
2901            .arg(repo)
2902            .arg("cat-file")
2903            .arg("-t")
2904            .arg(spec),
2905    )?;
2906    Ok(output.trim().to_string())
2907}
2908
2909struct BlobInfo {
2910    content: String,
2911    size: u64,
2912    binary: bool,
2913    displayable: bool,
2914}
2915
2916fn blob_info(repo: &Path, reference: &str, path: &str) -> Result<BlobInfo> {
2917    validate_git_path(path)?;
2918    let spec = format!("{reference}:{path}");
2919    let size = run_git(
2920        Command::new("git")
2921            .arg("--git-dir")
2922            .arg(repo)
2923            .arg("cat-file")
2924            .arg("-s")
2925            .arg(&spec),
2926    )?
2927    .trim()
2928    .parse::<u64>()
2929    .unwrap_or(0);
2930    if size > 256 * 1024 {
2931        return Ok(BlobInfo {
2932            content: format!("File is too large to display ({size} bytes)."),
2933            size,
2934            binary: false,
2935            displayable: false,
2936        });
2937    }
2938    let output = run_git_output(
2939        Command::new("git")
2940            .arg("--git-dir")
2941            .arg(repo)
2942            .arg("show")
2943            .arg(spec),
2944        GIT_COMMAND_TIMEOUT,
2945    )?;
2946    if !output.status.success() {
2947        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
2948    }
2949    let binary = output.stdout.contains(&0);
2950    Ok(BlobInfo {
2951        content: if binary {
2952            "Binary file is not displayed.".to_string()
2953        } else {
2954            String::from_utf8_lossy(&output.stdout).into_owned()
2955        },
2956        size,
2957        binary,
2958        displayable: !binary,
2959    })
2960}
2961
2962#[derive(Debug)]
2963struct CommitSummary {
2964    hash: String,
2965    subject: String,
2966    author: String,
2967    timestamp: i64,
2968}
2969
2970fn commits(repo: &Path, reference: &str, limit: usize) -> Result<Vec<CommitSummary>> {
2971    let format = "%H%x1f%s%x1f%an%x1f%at";
2972    let output = run_git(
2973        Command::new("git")
2974            .arg("--git-dir")
2975            .arg(repo)
2976            .arg("log")
2977            .arg(format!("--format={format}"))
2978            .arg("-n")
2979            .arg(limit.to_string())
2980            .arg(reference),
2981    )?;
2982    let mut commits = Vec::new();
2983    for line in output.lines() {
2984        let parts: Vec<&str> = line.split('\x1f').collect();
2985        if parts.len() == 4 {
2986            commits.push(CommitSummary {
2987                hash: parts[0].to_string(),
2988                subject: parts[1].to_string(),
2989                author: parts[2].to_string(),
2990                timestamp: parts[3].parse().unwrap_or_default(),
2991            });
2992        }
2993    }
2994    Ok(commits)
2995}
2996
2997#[derive(Debug)]
2998struct CommitInfo {
2999    hash: String,
3000    subject: String,
3001    body: String,
3002    author: String,
3003    email: String,
3004    timestamp: i64,
3005    files: Vec<CommitFile>,
3006}
3007
3008#[derive(Debug)]
3009struct CommitFile {
3010    status: String,
3011    path: String,
3012}
3013
3014fn commit_info(repo: &Path, hash: &str) -> Result<CommitInfo> {
3015    validate_refish(hash)?;
3016    let output = run_git(
3017        Command::new("git")
3018            .arg("--git-dir")
3019            .arg(repo)
3020            .arg("show")
3021            .arg("--no-patch")
3022            .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1f%at%x1f%B")
3023            .arg(hash),
3024    )?;
3025    let parts: Vec<&str> = output.splitn(6, '\x1f').collect();
3026    if parts.len() != 6 {
3027        return Err(Error::msg("invalid commit output"));
3028    }
3029    let files_output = run_git(
3030        Command::new("git")
3031            .arg("--git-dir")
3032            .arg(repo)
3033            .arg("diff-tree")
3034            .arg("--root")
3035            .arg("--no-commit-id")
3036            .arg("--name-status")
3037            .arg("-r")
3038            .arg(hash),
3039    )?;
3040    let mut files = Vec::new();
3041    for line in files_output.lines() {
3042        let Some((status, path)) = line.split_once('\t') else {
3043            continue;
3044        };
3045        files.push(CommitFile {
3046            status: status.to_string(),
3047            path: path.to_string(),
3048        });
3049    }
3050    Ok(CommitInfo {
3051        hash: parts[0].trim().to_string(),
3052        subject: parts[1].trim().to_string(),
3053        author: parts[2].trim().to_string(),
3054        email: parts[3].trim().to_string(),
3055        timestamp: parts[4].trim().parse().unwrap_or_default(),
3056        body: parts[5].trim().to_string(),
3057        files,
3058    })
3059}
3060
3061fn raw_commit_object(repo: &Path, hash: &str) -> Result<Vec<u8>> {
3062    validate_refish(hash)?;
3063    let output = run_git_output(
3064        Command::new("git")
3065            .arg("--git-dir")
3066            .arg(repo)
3067            .arg("cat-file")
3068            .arg("-p")
3069            .arg(hash),
3070        GIT_COMMAND_TIMEOUT,
3071    )?;
3072    if !output.status.success() {
3073        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3074    }
3075    Ok(output.stdout)
3076}
3077
3078fn run_git(cmd: &mut Command) -> Result<String> {
3079    let output = run_git_output(cmd, GIT_COMMAND_TIMEOUT)?;
3080    if !output.status.success() {
3081        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3082    }
3083    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
3084}
3085
3086fn run_git_output(cmd: &mut Command, timeout: Duration) -> Result<Output> {
3087    let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
3088    let deadline = Instant::now() + timeout;
3089
3090    loop {
3091        if child.try_wait()?.is_some() {
3092            return child.wait_with_output().map_err(Error::from);
3093        }
3094
3095        if Instant::now() >= deadline {
3096            let _ = child.kill();
3097            let _ = child.wait();
3098            log::warn!("Git command execution timed out");
3099            return Err(Error::msg("Git command execution timed out"));
3100        }
3101
3102        let sleep_for = deadline
3103            .saturating_duration_since(Instant::now())
3104            .min(Duration::from_millis(10));
3105        if !sleep_for.is_zero() {
3106            thread::sleep(sleep_for);
3107        }
3108    }
3109}
3110
3111#[cfg(test)]
3112mod tests {
3113    use super::*;
3114    use crate::acl::Access;
3115    use crate::config::ServerConfig;
3116
3117    #[test]
3118    fn blob_path_normalization_removes_only_the_explicit_relative_prefix() {
3119        assert_eq!(normalize_blob_path("./docs/guide.md"), "docs/guide.md");
3120        assert_eq!(normalize_blob_path("docs/./guide.md"), "docs/guide.md");
3121        assert_eq!(normalize_blob_path(".hidden/config"), ".hidden/config");
3122        assert_eq!(normalize_blob_path(".../guide.md"), ".../guide.md");
3123        assert_eq!(normalize_blob_path("/absolute.md"), "/absolute.md");
3124        assert!(validate_git_path(&normalize_blob_path("/absolute.md")).is_err());
3125    }
3126    use crate::logging;
3127    use rns_core::msgpack::{self, Value};
3128    use rns_crypto::OsRng;
3129    use std::fs;
3130    use std::process::Command;
3131
3132    #[test]
3133    fn nomadnet_destination_uses_upstream_name() {
3134        let identity = Identity::new(&mut OsRng);
3135        let destination = destination_for_identity(&identity);
3136        let expected =
3137            Destination::single_in("nomadnetwork", &["node"], IdentityHash(*identity.hash()));
3138        assert_eq!(destination.hash, expected.hash);
3139    }
3140
3141    #[test]
3142    fn chart_renders_positive_values_on_bottom_row() {
3143        let labels = ["start".to_string(), "end".to_string()];
3144        let chart = render_chart(&[1, 100], &labels, "57c", None, 1.3, 10);
3145        let bottom_row = chart.lines().nth(10).unwrap();
3146
3147        assert!(bottom_row.starts_with("│`FT"));
3148        assert!(bottom_row.contains('▄'));
3149    }
3150
3151    #[test]
3152    fn git_command_timeout_kills_slow_process() {
3153        let start = Instant::now();
3154
3155        let err =
3156            run_git_output(Command::new("sleep").arg("5"), Duration::from_millis(30)).unwrap_err();
3157
3158        assert!(start.elapsed() < Duration::from_secs(1));
3159        assert!(err.to_string().contains("timed out"));
3160    }
3161
3162    #[test]
3163    fn commit_message_formatter_escapes_leading_dash_lines() {
3164        let formatted = format_commit_message("subject\n- rendered literally\npath\\with`tick`");
3165
3166        assert_eq!(
3167            formatted,
3168            "subject\n\\- rendered literally\npath\\\\with\\`tick\\`"
3169        );
3170    }
3171
3172    #[test]
3173    fn front_page_lists_only_readable_groups() {
3174        let tmp = tempfile::tempdir().unwrap();
3175        let mut config = cfg(tmp.path());
3176        config.allow_read = vec!["none".into()];
3177        create_repo(
3178            config.repositories_dir.join("public/alpha"),
3179            "README.md",
3180            "alpha readme\n",
3181        );
3182        create_repo(
3183            config.repositories_dir.join("private/secret"),
3184            "README.md",
3185            "secret\n",
3186        );
3187        fs::write(
3188            config.repositories_dir.join("public/group.allowed"),
3189            "read = all\n",
3190        )
3191        .unwrap();
3192        fs::write(
3193            config.repositories_dir.join("private/group.allowed"),
3194            "read = none\n",
3195        )
3196        .unwrap();
3197        let access = access(&config);
3198
3199        let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
3200        assert!(page.contains("public"));
3201        assert!(page.contains("alpha"));
3202        assert!(!page.contains("private"));
3203        assert!(!page.contains("secret"));
3204    }
3205
3206    #[test]
3207    fn group_and_repo_pages_use_repository_metadata() {
3208        let tmp = tempfile::tempdir().unwrap();
3209        let config = cfg(tmp.path());
3210        let repo = create_repo(
3211            config.repositories_dir.join("public/alpha"),
3212            "README.md",
3213            "# Alpha\n",
3214        );
3215        fs::write(repo.join("description"), "Alpha repository\n").unwrap();
3216        let access = access(&config);
3217
3218        let group = render_page(
3219            PATH_GROUP,
3220            &config,
3221            &access,
3222            &page_request(&[("var_g", "public")]),
3223            None,
3224        )
3225        .unwrap();
3226        assert!(group.contains("Alpha repository"));
3227        assert!(group.contains("alpha"));
3228
3229        let repo = render_page(
3230            PATH_REPO,
3231            &config,
3232            &access,
3233            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3234            None,
3235        )
3236        .unwrap();
3237        assert!(repo.contains("rns://"));
3238        assert!(repo.contains("Alpha repository"));
3239        assert!(repo.contains(">Alpha"));
3240    }
3241
3242    #[test]
3243    fn repo_page_renders_fork_and_mirror_sources() {
3244        let tmp = tempfile::tempdir().unwrap();
3245        let config = cfg(tmp.path());
3246        let fork = create_repo(
3247            config.repositories_dir.join("public/forked"),
3248            "README.md",
3249            "# Forked\n",
3250        );
3251        let mirror = create_repo(
3252            config.repositories_dir.join("public/mirrored"),
3253            "README.md",
3254            "# Mirrored\n",
3255        );
3256        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3257            "config",
3258            "repository.rngit.type",
3259            "fork",
3260        ]));
3261        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3262            "config",
3263            "repository.rngit.upstream.source",
3264            "rns://00112233445566778899aabbccddeeff/source/repo",
3265        ]));
3266        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3267            "config",
3268            "repository.rngit.upstream.sync",
3269            "1",
3270        ]));
3271        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3272            "config",
3273            "repository.rngit.type",
3274            "mirror",
3275        ]));
3276        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3277            "config",
3278            "repository.rngit.upstream.source",
3279            "https://example.invalid/upstream.git",
3280        ]));
3281        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3282            "config",
3283            "repository.rngit.upstream.sync",
3284            "1",
3285        ]));
3286        let access = access(&config);
3287
3288        let fork_page = render_page(
3289            PATH_REPO,
3290            &config,
3291            &access,
3292            &page_request(&[("var_g", "public"), ("var_r", "forked")]),
3293            None,
3294        )
3295        .unwrap();
3296        assert!(fork_page.contains("Forked from"));
3297        assert!(fork_page.contains("rns://00112233445566778899aabbccddeeff/source/repo"));
3298        assert!(fork_page.contains("synced"));
3299        assert!(fork_page.contains("ago"));
3300
3301        let mirror_page = render_page(
3302            PATH_REPO,
3303            &config,
3304            &access,
3305            &page_request(&[("var_g", "public"), ("var_r", "mirrored")]),
3306            None,
3307        )
3308        .unwrap();
3309        assert!(mirror_page.contains("Mirrored from"));
3310        assert!(mirror_page.contains("https://example.invalid/upstream.git"));
3311        assert!(mirror_page.contains("synced"));
3312        assert!(mirror_page.contains("ago"));
3313    }
3314
3315    #[test]
3316    fn tree_blob_commits_commit_and_refs_pages_are_git_backed() {
3317        let tmp = tempfile::tempdir().unwrap();
3318        let config = cfg(tmp.path());
3319        let repo = create_repo(
3320            config.repositories_dir.join("public/alpha"),
3321            "src/lib.rs",
3322            "pub fn answer() -> u8 { 42 }\n",
3323        );
3324        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3325            "tag",
3326            "v1",
3327            "refs/heads/main",
3328        ]));
3329        let access = access(&config);
3330
3331        let tree = render_page(
3332            PATH_TREE,
3333            &config,
3334            &access,
3335            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3336            None,
3337        )
3338        .unwrap();
3339        assert!(tree.contains("src/"));
3340
3341        let blob = render_page(
3342            PATH_BLOB,
3343            &config,
3344            &access,
3345            &page_request(&[
3346                ("var_g", "public"),
3347                ("var_r", "alpha"),
3348                ("var_path", "src/lib.rs"),
3349            ]),
3350            None,
3351        )
3352        .unwrap();
3353        assert!(blob.contains("answer"));
3354        assert!(blob.contains("pub fn answer()"));
3355
3356        let commits = render_page(
3357            PATH_COMMITS,
3358            &config,
3359            &access,
3360            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3361            None,
3362        )
3363        .unwrap();
3364        assert!(commits.contains("initial"));
3365
3366        let commit_hash = run_git(
3367            Command::new("git")
3368                .arg("--git-dir")
3369                .arg(&repo)
3370                .args(["rev-parse", "refs/heads/main"]),
3371        );
3372        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3373            "update-ref",
3374            "refs/heads/feature",
3375            commit_hash.trim(),
3376        ]));
3377        let branch_commits = render_page(
3378            PATH_COMMITS,
3379            &config,
3380            &access,
3381            &page_request(&[
3382                ("var_g", "public"),
3383                ("var_r", "alpha"),
3384                ("var_ref", "feature"),
3385            ]),
3386            None,
3387        )
3388        .unwrap();
3389        assert!(branch_commits.contains("|ref=feature|h="));
3390        let commit = render_page(
3391            PATH_COMMIT,
3392            &config,
3393            &access,
3394            &page_request(&[
3395                ("var_g", "public"),
3396                ("var_r", "alpha"),
3397                ("var_ref", "feature"),
3398                ("var_h", commit_hash.trim()),
3399            ]),
3400            None,
3401        )
3402        .unwrap();
3403        assert!(commit.contains("initial"));
3404        assert!(commit.contains("src/lib.rs"));
3405        assert!(commit.contains("/page/commits.mu`g=public|r=alpha|ref=feature"));
3406        assert!(!commit.contains("Signature:"));
3407
3408        let refs = render_page(
3409            PATH_REFS,
3410            &config,
3411            &access,
3412            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3413            None,
3414        )
3415        .unwrap();
3416        assert!(refs.contains("main"));
3417        assert!(refs.contains("v1"));
3418    }
3419
3420    #[test]
3421    fn commit_page_shows_valid_reticulum_signature_status() {
3422        let tmp = tempfile::tempdir().unwrap();
3423        let config = cfg(tmp.path());
3424        let repo = create_repo(
3425            config.repositories_dir.join("public/alpha"),
3426            "src/lib.rs",
3427            "pub fn answer() -> u8 { 42 }\n",
3428        );
3429        let access = access(&config);
3430        let tree = run_git(
3431            Command::new("git")
3432                .arg("--git-dir")
3433                .arg(&repo)
3434                .args(["rev-parse", "refs/heads/main^{tree}"]),
3435        );
3436        let identity = Identity::new(&mut OsRng);
3437        let author = crate::util::hex(identity.hash());
3438        let signed_content = format!(
3439            "tree {}\nauthor RNS Page Test <{}> 0 +0000\ncommitter RNS Page Test <{}> 0 +0000\n\nsigned commit\n",
3440            tree.trim(),
3441            author,
3442            author
3443        );
3444        let armored =
3445            crate::commitsigs::sign_message(&identity, signed_content.as_bytes()).unwrap();
3446        let (headers, rest) = signed_content.split_once("\n\n").unwrap();
3447        let mut raw_commit = format!("{headers}\n");
3448        let mut sig_lines = armored.lines();
3449        raw_commit.push_str("gpgsig ");
3450        raw_commit.push_str(sig_lines.next().unwrap());
3451        raw_commit.push('\n');
3452        for line in sig_lines {
3453            raw_commit.push(' ');
3454            raw_commit.push_str(line);
3455            raw_commit.push('\n');
3456        }
3457        raw_commit.push('\n');
3458        raw_commit.push_str(rest);
3459        let commit_file = tmp.path().join("signed-commit");
3460        fs::write(&commit_file, raw_commit).unwrap();
3461        let commit_hash = run_git(
3462            Command::new("git")
3463                .arg("--git-dir")
3464                .arg(&repo)
3465                .args(["hash-object", "-t", "commit", "-w"])
3466                .arg(&commit_file),
3467        );
3468        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3469            "update-ref",
3470            "refs/heads/main",
3471            commit_hash.trim(),
3472        ]));
3473
3474        let page = render_page(
3475            PATH_COMMIT,
3476            &config,
3477            &access,
3478            &page_request(&[
3479                ("var_g", "public"),
3480                ("var_r", "alpha"),
3481                ("var_h", commit_hash.trim()),
3482            ]),
3483            None,
3484        )
3485        .unwrap();
3486
3487        assert!(page.contains("signed commit"));
3488        assert!(page.contains("Signature: Valid, signed by author"));
3489    }
3490
3491    #[test]
3492    fn blob_page_renders_tree_when_path_is_directory() {
3493        let tmp = tempfile::tempdir().unwrap();
3494        let config = cfg(tmp.path());
3495        create_repo(
3496            config.repositories_dir.join("public/alpha"),
3497            "src/lib.rs",
3498            "pub fn answer() -> u8 { 42 }\n",
3499        );
3500        let access = access(&config);
3501
3502        let page = render_page(
3503            PATH_BLOB,
3504            &config,
3505            &access,
3506            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_path", "src")]),
3507            None,
3508        )
3509        .unwrap();
3510
3511        assert!(page.contains("files / src"));
3512        assert!(page.contains("lib.rs"));
3513        assert!(page.contains(PATH_BLOB));
3514        assert!(!page.contains("Displaying Raw"));
3515    }
3516
3517    #[test]
3518    fn page_requests_reject_invalid_repository_names() {
3519        let tmp = tempfile::tempdir().unwrap();
3520        let config = cfg(tmp.path());
3521        let access = access(&config);
3522        let err = render_page(
3523            PATH_REPO,
3524            &config,
3525            &access,
3526            &page_request(&[("var_g", ".."), ("var_r", "repo")]),
3527            None,
3528        )
3529        .unwrap_err();
3530        assert!(err.to_string().contains("invalid repository name"));
3531    }
3532
3533    #[test]
3534    fn page_ref_resolution_rejects_invalid_refs_before_git_lookup() {
3535        let tmp = tempfile::tempdir().unwrap();
3536        let config = cfg(tmp.path());
3537        create_repo(
3538            config.repositories_dir.join("public/alpha"),
3539            "README.md",
3540            "# Alpha\n",
3541        );
3542        let access = access(&config);
3543
3544        for (path, vars) in [
3545            (
3546                PATH_TREE,
3547                vec![
3548                    ("var_g", "public"),
3549                    ("var_r", "alpha"),
3550                    ("var_ref", "--upload-pack=/tmp/x"),
3551                ],
3552            ),
3553            (
3554                PATH_BLOB,
3555                vec![
3556                    ("var_g", "public"),
3557                    ("var_r", "alpha"),
3558                    ("var_path", "README.md"),
3559                    ("var_ref", "refs/heads/main:README.md"),
3560                ],
3561            ),
3562            (
3563                PATH_COMMITS,
3564                vec![
3565                    ("var_g", "public"),
3566                    ("var_r", "alpha"),
3567                    ("var_ref", "refs/heads/main..refs/heads/other"),
3568                ],
3569            ),
3570        ] {
3571            let err = render_page(path, &config, &access, &page_request(&vars), None).unwrap_err();
3572            assert_eq!(err.to_string(), "invalid git ref");
3573        }
3574    }
3575
3576    #[test]
3577    fn pages_use_base_template_breadcrumbs_and_micron_links() {
3578        let tmp = tempfile::tempdir().unwrap();
3579        let config = cfg(tmp.path());
3580        create_repo(
3581            config.repositories_dir.join("public/alpha"),
3582            "README.md",
3583            "# Alpha\n",
3584        );
3585        let access = access(&config);
3586
3587        let repo = render_page(
3588            PATH_REPO,
3589            &config,
3590            &access,
3591            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3592            None,
3593        )
3594        .unwrap();
3595
3596        assert!(repo.starts_with("#!c=0\n>Test Git Node\n\n>>\n"));
3597        assert!(repo.contains("`!`[Node`:/page/index.mu]`!"));
3598        assert!(repo.contains("`!`[public`:/page/group.mu`g=public]`!"));
3599        assert!(repo.contains("`[Files`:/page/tree.mu`g=public|r=alpha|ref=HEAD]"));
3600        assert!(repo.contains("`[Commits"));
3601        assert!(repo.contains("`[Branches (1)`:/page/refs.mu`g=public|r=alpha|type=heads]"));
3602        assert!(repo.contains("`[Tags (0)`:/page/refs.mu`g=public|r=alpha|type=tags]"));
3603        assert!(repo.contains("<\n-\n`a`F666`[Served by rngit`:/page/index.mu]"));
3604    }
3605
3606    #[test]
3607    fn custom_templates_wrap_page_content_and_replace_variables() {
3608        let tmp = tempfile::tempdir().unwrap();
3609        let config = cfg(tmp.path());
3610        fs::create_dir_all(&config.templates_dir).unwrap();
3611        fs::write(
3612            config.templates_dir.join("base.mu"),
3613            "BASE {NODE_NAME} {VERSION} {GEN_TIME}\n{PAGE_CONTENT}\n{NAVIGATION}   \n",
3614        )
3615        .unwrap();
3616        fs::write(
3617            config.templates_dir.join("repo.mu"),
3618            "REPO-BEGIN\n{PAGE_CONTENT}\nREPO-END   \n",
3619        )
3620        .unwrap();
3621        create_repo(
3622            config.repositories_dir.join("public/alpha"),
3623            "README.md",
3624            "# Alpha\n",
3625        );
3626        let access = access(&config);
3627
3628        let repo = render_page(
3629            PATH_REPO,
3630            &config,
3631            &access,
3632            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3633            None,
3634        )
3635        .unwrap();
3636
3637        assert!(repo.starts_with("BASE Test Git Node "));
3638        assert!(repo.contains("\nREPO-BEGIN\n"));
3639        assert!(repo.contains(">Alpha"));
3640        assert!(repo.contains("\nREPO-END\n"));
3641        assert!(repo.contains(env!("CARGO_PKG_VERSION")));
3642        assert!(repo.contains("0ms"));
3643        assert!(!repo.ends_with("   \n"));
3644    }
3645
3646    #[test]
3647    fn template_substitution_does_not_rewrite_page_content_placeholders() {
3648        let tmp = tempfile::tempdir().unwrap();
3649        let config = cfg(tmp.path());
3650
3651        let out = apply_template(
3652            "Template {NODE_NAME}\n{PAGE_CONTENT}",
3653            "Literal {NODE_NAME}",
3654            &config,
3655        );
3656
3657        assert!(out.starts_with("Template Test Git Node\n"));
3658        assert!(out.ends_with("Literal {NODE_NAME}"));
3659    }
3660
3661    #[test]
3662    fn unicode_icon_config_changes_repository_navigation_labels() {
3663        let tmp = tempfile::tempdir().unwrap();
3664        let mut config = cfg(tmp.path());
3665        config.unicode_icons = true;
3666        create_repo(
3667            config.repositories_dir.join("public/alpha"),
3668            "README.md",
3669            "# Alpha\n",
3670        );
3671        let access = access(&config);
3672
3673        let repo = render_page(
3674            PATH_REPO,
3675            &config,
3676            &access,
3677            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3678            None,
3679        )
3680        .unwrap();
3681
3682        assert!(repo.contains("🗀 Files"));
3683        assert!(repo.contains("🖹 Commits"));
3684        assert!(repo.contains("⑃ Branches"));
3685        assert!(repo.contains("⌆ Tags"));
3686    }
3687
3688    #[test]
3689    fn repo_page_renders_and_persists_thanks_count() {
3690        let tmp = tempfile::tempdir().unwrap();
3691        let config = cfg(tmp.path());
3692        let repo_path = create_repo(
3693            config.repositories_dir.join("public/alpha"),
3694            "README.md",
3695            "# Alpha\n",
3696        );
3697        let access = access(&config);
3698
3699        let first = render_page(
3700            PATH_REPO,
3701            &config,
3702            &access,
3703            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3704            None,
3705        )
3706        .unwrap();
3707        assert!(first.contains("Thanks (0)"));
3708        assert!(!repo_path.with_extension("thanks").exists());
3709
3710        let thanked = render_page(
3711            PATH_REPO,
3712            &config,
3713            &access,
3714            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
3715            None,
3716        )
3717        .unwrap();
3718        assert!(thanked.contains("Thanks (1)"));
3719        assert_eq!(
3720            fs::read_to_string(repo_path.with_extension("thanks")).unwrap(),
3721            "1\n"
3722        );
3723
3724        let incremented = render_page(
3725            PATH_REPO,
3726            &config,
3727            &access,
3728            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
3729            None,
3730        )
3731        .unwrap();
3732        assert!(incremented.contains("Thanks (2)"));
3733
3734        let viewed = render_page(
3735            PATH_REPO,
3736            &config,
3737            &access,
3738            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3739            None,
3740        )
3741        .unwrap();
3742        assert!(viewed.contains("Thanks (2)"));
3743    }
3744
3745    #[test]
3746    fn repo_page_uses_unicode_heart_for_thanks_when_enabled() {
3747        let tmp = tempfile::tempdir().unwrap();
3748        let mut config = cfg(tmp.path());
3749        config.unicode_icons = true;
3750        create_repo(
3751            config.repositories_dir.join("public/alpha"),
3752            "README.md",
3753            "# Alpha\n",
3754        );
3755        let access = access(&config);
3756
3757        let page = render_page(
3758            PATH_REPO,
3759            &config,
3760            &access,
3761            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3762            None,
3763        )
3764        .unwrap();
3765
3766        assert!(page.contains("♥ Thanks (0)"));
3767        assert!(page.contains("thanks=y"));
3768    }
3769
3770    #[test]
3771    fn empty_page_request_data_is_deterministic() {
3772        let tmp = tempfile::tempdir().unwrap();
3773        let config = cfg(tmp.path());
3774        let access = access(&config);
3775
3776        let index = render_page(PATH_INDEX, &config, &access, &[], None).unwrap();
3777        assert!(index.contains(">Groups"));
3778
3779        let err = render_page(PATH_REPO, &config, &access, &[], None).unwrap_err();
3780        assert!(err.to_string().contains("missing page variable g"));
3781    }
3782
3783    #[test]
3784    fn tree_blob_and_commit_pages_escape_content_and_format_metadata() {
3785        let tmp = tempfile::tempdir().unwrap();
3786        let config = cfg(tmp.path());
3787        let repo = create_repo(
3788            config.repositories_dir.join("public/alpha"),
3789            "src/lib.rs",
3790            "pub fn `answer`() -> u8 { 42 }\n",
3791        );
3792        let access = access(&config);
3793
3794        let tree = render_page(
3795            PATH_TREE,
3796            &config,
3797            &access,
3798            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3799            None,
3800        )
3801        .unwrap();
3802        assert!(tree.contains("`[src/`:/page/tree.mu`g=public|r=alpha|ref=HEAD|path=src]"));
3803
3804        let blob = render_page(
3805            PATH_BLOB,
3806            &config,
3807            &access,
3808            &page_request(&[
3809                ("var_g", "public"),
3810                ("var_r", "alpha"),
3811                ("var_path", "src/lib.rs"),
3812            ]),
3813            None,
3814        )
3815        .unwrap();
3816        assert!(blob.contains("Text, "));
3817        assert!(!blob.contains("View raw"));
3818        assert!(blob.contains("answer"));
3819        assert!(blob.contains("\\`"));
3820
3821        let silly_blob = render_page(
3822            PATH_BLOB,
3823            &config,
3824            &access,
3825            &page_request(&[
3826                ("var_g", "public"),
3827                ("var_r", "alpha"),
3828                ("var_path", "./src/./lib.rs"),
3829            ]),
3830            None,
3831        )
3832        .unwrap();
3833        assert!(silly_blob.contains(">src/lib.rs `F666"));
3834        assert!(silly_blob.contains("answer"));
3835
3836        let commit_hash = run_git(
3837            Command::new("git")
3838                .arg("--git-dir")
3839                .arg(&repo)
3840                .args(["rev-parse", "refs/heads/main"]),
3841        );
3842        let commit = render_page(
3843            PATH_COMMIT,
3844            &config,
3845            &access,
3846            &page_request(&[
3847                ("var_g", "public"),
3848                ("var_r", "alpha"),
3849                ("var_h", commit_hash.trim()),
3850            ]),
3851            None,
3852        )
3853        .unwrap();
3854        assert!(commit.contains("Author: RNS Page Test"));
3855        assert!(commit.contains("Files changed"));
3856        assert!(commit.contains("A src/lib.rs"));
3857    }
3858
3859    #[test]
3860    fn markdown_converter_handles_links_code_lists_rules_and_tables() {
3861        let input = "# Title\n\
3862\n\
3863See [the docs](https://example.invalid/a_b) and `literal *code*`.\n\
3864\n\
3865- **bold** and *italic*\n\
3866---\n\
3867| Name | Link |\n\
3868| --- | --- |\n\
3869| Row | [go](rns://abc) |\n";
3870
3871        let out = markdown_to_micron(input);
3872        assert!(out.contains(">Title"));
3873        assert!(out.contains("`_`!`[the docs`https://example.invalid/a_b]`!`_"));
3874        assert!(out.contains("`BT383838`Fdddliteral *code*`f`b"));
3875        assert!(out.contains(" • `!bold`! and `*italic`*"));
3876        assert!(out.contains("\n-\n"));
3877        assert!(out.contains("│ Name"));
3878        assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
3879    }
3880
3881    #[test]
3882    fn markdown_converter_handles_blockquotes_and_heading_inline_markup() {
3883        let out = markdown_to_micron(
3884            "# **Important**\n\
3885> **Quoted** text\n\
3886> with [link](rns://abc)\n\
3887\n\
3888after\n",
3889        );
3890        assert!(out.contains(">`!Important`!"));
3891        assert!(out.contains(" │ `!Quoted`! text with `_`!`[link`rns://abc]`!`_"));
3892        assert!(out.contains("\nafter\n"));
3893    }
3894
3895    #[test]
3896    fn markdown_link_conversion_is_isolated_from_later_substitutions() {
3897        let out = markdown_to_micron("[**bold** `code`](https://example.invalid?q=*x*)");
3898        assert!(out.contains("`_`!`[**bold** code`https://example.invalid?q=*x*]`!`_"));
3899        assert!(!out.contains("`!`!"));
3900        assert!(!out.contains("`*x`*"));
3901    }
3902
3903    #[test]
3904    fn markdown_link_style_can_disable_wrappers_and_apply_color() {
3905        let plain = format_markdown_link(
3906            "label",
3907            "rns://abc",
3908            None,
3909            MarkdownLinkStyle {
3910                bold: false,
3911                underline: false,
3912                color: None,
3913            },
3914        );
3915        assert_eq!(plain, "`[label`rns://abc]");
3916
3917        let short_color = format_markdown_link(
3918            "label",
3919            "rns://abc",
3920            None,
3921            MarkdownLinkStyle {
3922                bold: true,
3923                underline: false,
3924                color: Some("abc"),
3925            },
3926        );
3927        assert_eq!(short_color, "`Fabc`!`[label`rns://abc]`!`f");
3928
3929        let true_color = format_markdown_link(
3930            "label",
3931            "rns://abc",
3932            None,
3933            MarkdownLinkStyle {
3934                bold: false,
3935                underline: true,
3936                color: Some("aabbcc"),
3937            },
3938        );
3939        assert_eq!(true_color, "`FTaabbcc`_`[label`rns://abc]`_`f");
3940    }
3941
3942    #[test]
3943    fn markdown_inline_code_that_looks_like_link_does_not_hide_real_links() {
3944        let out = markdown_to_micron(
3945            "`[literal](https://example.invalid/no)` and [real](https://example.invalid/yes)",
3946        );
3947
3948        assert!(out.contains("`BT383838`Fddd[literal](https://example.invalid/no)`f`b"));
3949        assert!(out.contains("`_`!`[real`https://example.invalid/yes]`!`_"));
3950        assert!(!out.contains("`_`!`[literal`https://example.invalid/no]`!`_"));
3951    }
3952
3953    #[test]
3954    fn markdown_tables_handle_empty_cells_escaped_pipes_alignment_and_visible_width() {
3955        let input = "| Name | Status | Notes |\n\
3956| :--- | ---: | :---: |\n\
3957| `code` | **ok** | a\\|b |\n\
3958| empty | | [go](rns://abc) |\n";
3959
3960        let out = markdown_to_micron(input);
3961        assert!(out.contains("┌"));
3962        assert!(out.contains("├"));
3963        assert!(out.contains("└"));
3964        assert!(out.contains("│ Name"));
3965        assert!(out.contains("`BT383838`Fdddcode`f`b"));
3966        assert!(out.contains("`!ok`!"));
3967        assert!(out.contains("a|b"));
3968        assert!(out.contains("empty"));
3969        assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
3970        assert!(!out.contains("a\\|b"));
3971    }
3972
3973    #[test]
3974    fn markdown_table_width_ignores_generated_micron_tags() {
3975        let input = "| A | B |\n\
3976| --- | --- |\n\
3977| [x](rns://abc) | `y` |\n";
3978
3979        let out = markdown_to_micron(input);
3980        assert!(out.contains("│ `_`!`[x`rns://abc]`!`_ "));
3981        assert!(out.contains("│ `BT383838`Fdddy`f`b "));
3982        assert!(out.contains("┌─────┬─────┐"));
3983    }
3984
3985    #[test]
3986    fn markdown_escaping_does_not_create_or_corrupt_micron_tags() {
3987        let out =
3988            markdown_to_micron("literal `!not bold`! and [bad`label](rns://abc`def) plus `raw`");
3989        assert!(out.contains("literal \\`!not bold\\`!"));
3990        assert!(out.contains("`_`!`[badlabel`rns://abcdef]`!`_"));
3991        assert!(out.contains("`BT383838`Fdddraw`f`b"));
3992        assert!(!out.contains("`!not bold`!"));
3993    }
3994
3995    #[test]
3996    fn markdown_escapes_backslashes_and_expands_tabs_before_inline_formatting() {
3997        let out = markdown_to_micron("plain\\path\t**bold\\path** and `code\\path`\n");
3998
3999        assert!(out.contains("plain\\\\path   `!bold\\\\path`!"));
4000        assert!(out.contains("`BT383838`Fdddcode\\\\path`f`b"));
4001    }
4002
4003    #[test]
4004    fn markdown_plain_lines_escape_micron_line_start_controls() {
4005        let out = markdown_to_micron("-not a list\n<not alignment\n- list item\n---\n");
4006        assert!(out.contains("\\-not a list\n"));
4007        assert!(out.contains("\\<not alignment\n"));
4008        assert!(out.contains(" • list item\n"));
4009        assert!(out.contains("-\n"));
4010    }
4011
4012    #[test]
4013    fn markdown_converter_keeps_code_blocks_and_unmatched_markers_literal() {
4014        let out = markdown_to_micron(
4015            "```rust\n\
4016**not bold** and `not inline`\n\
4017```\n\
4018Unmatched * marker\n\
40191. numbered\n",
4020        );
4021        assert!(out.contains("not bold"));
4022        assert!(out.contains("\\`not inline\\`"));
4023        assert!(out.contains("`=\n**not bold** and \\`not inline\\`\n`=\n"));
4024        assert!(!out.contains("`!not bold`!"));
4025        assert!(out.contains("Unmatched * marker"));
4026        assert!(out.contains("1. numbered"));
4027    }
4028
4029    #[test]
4030    fn rawmu_fenced_blocks_pass_through_without_escaping() {
4031        let markdown = markdown_to_micron(
4032            "```rawmu
4033`!raw micron`!
4034>Heading
4035```
4036",
4037        );
4038
4039        assert!(markdown.contains(
4040            "`!raw micron`!
4041>Heading
4042"
4043        ));
4044        assert!(!markdown.contains(r"\`!raw micron"));
4045        assert!(!markdown.contains(
4046            "`=
4047"
4048        ));
4049    }
4050
4051    #[test]
4052    fn unknown_extension_and_fence_language_fall_back_to_plain_literals() {
4053        let blob = crate::highlight::literal_block("value `tick`\n", Some("blob.unknown"), None);
4054        assert!(!blob.contains("`FT"));
4055        assert!(blob.contains("value \\`tick\\`"));
4056
4057        let markdown = markdown_to_micron("```not-a-language\nvalue `tick`\n```\n");
4058        assert!(!markdown.contains("`FT"));
4059        assert!(markdown.contains("value \\`tick\\`"));
4060    }
4061
4062    #[test]
4063    fn binary_and_oversized_blobs_use_plain_fallback_messages() {
4064        let tmp = tempfile::tempdir().unwrap();
4065        let config = cfg(tmp.path());
4066        create_repo_bytes(
4067            config.repositories_dir.join("public/binary"),
4068            "payload.bin",
4069            b"abc\0def",
4070        );
4071        let large = vec![b'x'; 256 * 1024 + 1];
4072        create_repo_bytes(
4073            config.repositories_dir.join("public/large"),
4074            "payload.rs",
4075            &large,
4076        );
4077        let access = access(&config);
4078
4079        let binary = render_page(
4080            PATH_BLOB,
4081            &config,
4082            &access,
4083            &page_request(&[
4084                ("var_g", "public"),
4085                ("var_r", "binary"),
4086                ("var_path", "payload.bin"),
4087            ]),
4088            None,
4089        )
4090        .unwrap();
4091        assert!(binary.contains("Binary"));
4092        assert!(binary.contains("Binary file is not displayed."));
4093        assert!(!binary.contains("`FT"));
4094
4095        let oversized = render_page(
4096            PATH_BLOB,
4097            &config,
4098            &access,
4099            &page_request(&[
4100                ("var_g", "public"),
4101                ("var_r", "large"),
4102                ("var_path", "payload.rs"),
4103            ]),
4104            None,
4105        )
4106        .unwrap();
4107        assert!(oversized.contains("File is too large to display"));
4108        assert!(!oversized.contains("`FT"));
4109    }
4110
4111    #[test]
4112    fn markdown_blob_defaults_to_rendered_view_and_raw_view_escapes_source() {
4113        let tmp = tempfile::tempdir().unwrap();
4114        let config = cfg(tmp.path());
4115        create_repo(
4116            config.repositories_dir.join("public/docs"),
4117            "docs/readme.md",
4118            "# Title\n\nSee [docs](https://example.invalid), [guide](guide.md#intro) and `tick`.\n",
4119        );
4120        let access = access(&config);
4121
4122        let rendered = render_page(
4123            PATH_BLOB,
4124            &config,
4125            &access,
4126            &page_request(&[
4127                ("var_g", "public"),
4128                ("var_r", "docs"),
4129                ("var_path", "docs/readme.md"),
4130            ]),
4131            None,
4132        )
4133        .unwrap();
4134        assert!(rendered.contains("Displaying Rendered"));
4135        assert!(rendered.contains("View raw"));
4136        assert!(rendered.contains(">Title"));
4137        assert!(rendered.contains("`_`!`[docs`https://example.invalid]`!`_"));
4138        assert!(rendered.contains(
4139            "`_`!`[guide`:/page/blob.mu`g=public|r=docs|ref=HEAD|path=docs/guide.md|anchor=intro]`!`_"
4140        ));
4141        assert!(rendered.contains("`BT383838`Fdddtick`f`b"));
4142        assert!(!rendered.contains("# Title"));
4143
4144        let raw = render_page(
4145            PATH_BLOB,
4146            &config,
4147            &access,
4148            &page_request(&[
4149                ("var_g", "public"),
4150                ("var_r", "docs"),
4151                ("var_path", "docs/readme.md"),
4152                ("var_raw", "y"),
4153            ]),
4154            None,
4155        )
4156        .unwrap();
4157        assert!(raw.contains("Displaying Raw"));
4158        assert!(raw.contains("View rendered"));
4159        assert!(raw.contains("Title"));
4160        assert!(raw.contains("\\`"));
4161        assert!(raw.contains("tick"));
4162        assert!(!raw.contains("`_`!`[docs`https://example.invalid]`!`_"));
4163    }
4164
4165    #[test]
4166    fn micron_blob_defaults_to_rendered_passthrough_and_raw_view_is_literal() {
4167        let tmp = tempfile::tempdir().unwrap();
4168        let config = cfg(tmp.path());
4169        create_repo(
4170            config.repositories_dir.join("public/micron"),
4171            "README.mu",
4172            ">Micron\n\n`!Already formatted`!\n",
4173        );
4174        let access = access(&config);
4175
4176        let rendered = render_page(
4177            PATH_BLOB,
4178            &config,
4179            &access,
4180            &page_request(&[
4181                ("var_g", "public"),
4182                ("var_r", "micron"),
4183                ("var_path", "README.mu"),
4184            ]),
4185            None,
4186        )
4187        .unwrap();
4188        assert!(rendered.contains("Displaying Rendered"));
4189        assert!(rendered.contains(">Micron"));
4190        assert!(rendered.contains("`!Already formatted`!"));
4191        assert!(!rendered.contains("\\`!Already formatted\\`!"));
4192
4193        let raw = render_page(
4194            PATH_BLOB,
4195            &config,
4196            &access,
4197            &page_request(&[
4198                ("var_g", "public"),
4199                ("var_r", "micron"),
4200                ("var_path", "README.mu"),
4201                ("var_raw", "y"),
4202            ]),
4203            None,
4204        )
4205        .unwrap();
4206        assert!(raw.contains("Displaying Raw"));
4207        assert!(raw.contains("\\`!Already formatted\\`!"));
4208    }
4209
4210    #[test]
4211    fn explicit_render_parameter_renders_markdown_blob() {
4212        let tmp = tempfile::tempdir().unwrap();
4213        let config = cfg(tmp.path());
4214        create_repo(
4215            config.repositories_dir.join("public/docs"),
4216            "docs/readme.md",
4217            "# Explicit\n",
4218        );
4219        let access = access(&config);
4220
4221        let rendered = render_page(
4222            PATH_BLOB,
4223            &config,
4224            &access,
4225            &page_request(&[
4226                ("var_g", "public"),
4227                ("var_r", "docs"),
4228                ("var_path", "docs/readme.md"),
4229                ("var_raw", "y"),
4230                ("var_render", "y"),
4231            ]),
4232            None,
4233        )
4234        .unwrap();
4235        assert!(rendered.contains("Displaying Raw"));
4236        assert!(rendered.contains("#"));
4237        assert!(rendered.contains("Explicit"));
4238
4239        let rendered = render_page(
4240            PATH_BLOB,
4241            &config,
4242            &access,
4243            &page_request(&[
4244                ("var_g", "public"),
4245                ("var_r", "docs"),
4246                ("var_path", "docs/readme.md"),
4247                ("var_render", "y"),
4248            ]),
4249            None,
4250        )
4251        .unwrap();
4252        assert!(rendered.contains("Displaying Rendered"));
4253        assert!(rendered.contains(">Explicit"));
4254        assert!(!rendered.contains("# Explicit"));
4255    }
4256
4257    #[test]
4258    fn unsupported_blob_extension_ignores_render_parameter_and_uses_raw_literal() {
4259        let tmp = tempfile::tempdir().unwrap();
4260        let config = cfg(tmp.path());
4261        create_repo(
4262            config.repositories_dir.join("public/text"),
4263            "notes.txt",
4264            "# Not a heading\npath\\name\t`literal`\n",
4265        );
4266        let access = access(&config);
4267
4268        let page = render_page(
4269            PATH_BLOB,
4270            &config,
4271            &access,
4272            &page_request(&[
4273                ("var_g", "public"),
4274                ("var_r", "text"),
4275                ("var_path", "notes.txt"),
4276                ("var_render", "y"),
4277            ]),
4278            None,
4279        )
4280        .unwrap();
4281        assert!(!page.contains("Displaying Rendered"));
4282        assert!(!page.contains("View raw"));
4283        assert!(page.contains("Displaying Raw"));
4284        assert!(page.contains("Download"));
4285        assert!(page.contains("Not a heading"));
4286        assert!(page.contains("path\\\\name   \\`literal\\`"));
4287        assert!(page.contains("\\`literal\\`"));
4288    }
4289
4290    #[test]
4291    fn renderable_binary_and_oversized_blobs_do_not_render_content() {
4292        let tmp = tempfile::tempdir().unwrap();
4293        let config = cfg(tmp.path());
4294        create_repo_bytes(
4295            config.repositories_dir.join("public/binary-md"),
4296            "payload.md",
4297            b"# Binary\0payload\n",
4298        );
4299        let large = vec![b'#'; 256 * 1024 + 1];
4300        create_repo_bytes(
4301            config.repositories_dir.join("public/large-md"),
4302            "payload.md",
4303            &large,
4304        );
4305        let access = access(&config);
4306
4307        let binary = render_page(
4308            PATH_BLOB,
4309            &config,
4310            &access,
4311            &page_request(&[
4312                ("var_g", "public"),
4313                ("var_r", "binary-md"),
4314                ("var_path", "payload.md"),
4315            ]),
4316            None,
4317        )
4318        .unwrap();
4319        assert!(binary.contains("Binary"));
4320        assert!(binary.contains("Binary file is not displayed."));
4321        assert!(!binary.contains(">Binary"));
4322
4323        let oversized = render_page(
4324            PATH_BLOB,
4325            &config,
4326            &access,
4327            &page_request(&[
4328                ("var_g", "public"),
4329                ("var_r", "large-md"),
4330                ("var_path", "payload.md"),
4331                ("var_render", "y"),
4332            ]),
4333            None,
4334        )
4335        .unwrap();
4336        assert!(oversized.contains("File is too large to display"));
4337        assert!(!oversized.contains("Displaying Rendered\n\n>"));
4338    }
4339
4340    #[test]
4341    fn code_blocks_render_plain_literal_blocks() {
4342        let blob = crate::highlight::literal_block("pub fn main() {}\n", Some("main.rs"), None);
4343        assert!(!blob.contains("`FT"));
4344        assert!(blob.contains("pub fn main() {}"));
4345
4346        let markdown = markdown_to_micron("```rust\npub fn main() {}\n```\n");
4347        assert!(!markdown.contains("`FT"));
4348        assert!(markdown.contains("pub fn main() {}"));
4349    }
4350
4351    #[test]
4352    fn repo_page_renders_markdown_readme_and_passes_micron_readme_through() {
4353        let tmp = tempfile::tempdir().unwrap();
4354        let config = cfg(tmp.path());
4355        create_repo(
4356            config.repositories_dir.join("public/markdown"),
4357            "README.md",
4358            "# Markdown\n\nSee [docs](https://example.invalid) and [local](docs/intro.md).\n",
4359        );
4360        create_repo(
4361            config.repositories_dir.join("public/micron"),
4362            "README.mu",
4363            ">Micron\n\n`!Already formatted`!\n",
4364        );
4365        create_repo(
4366            config.repositories_dir.join("public/microntight"),
4367            "README.mu",
4368            ">Micron without trailing newline",
4369        );
4370        create_repo(
4371            config.repositories_dir.join("public/micronblank"),
4372            "README.mu",
4373            ">Micron with trailing blanks\n\n\n",
4374        );
4375        let access = access(&config);
4376
4377        let markdown = render_page(
4378            PATH_REPO,
4379            &config,
4380            &access,
4381            &page_request(&[("var_g", "public"), ("var_r", "markdown")]),
4382            None,
4383        )
4384        .unwrap();
4385        assert!(markdown.contains(">Markdown"));
4386        assert!(markdown.contains("`_`!`[docs`https://example.invalid]`!`_"));
4387        assert!(markdown.contains(
4388            "`_`!`[local`:/page/blob.mu`g=public|r=markdown|ref=HEAD|path=docs/intro.md]`!`_"
4389        ));
4390
4391        let micron = render_page(
4392            PATH_REPO,
4393            &config,
4394            &access,
4395            &page_request(&[("var_g", "public"), ("var_r", "micron")]),
4396            None,
4397        )
4398        .unwrap();
4399        assert!(micron.contains(">Micron"));
4400        assert!(micron.contains("`!Already formatted`!"));
4401
4402        let micron = render_page(
4403            PATH_REPO,
4404            &config,
4405            &access,
4406            &page_request(&[("var_g", "public"), ("var_r", "microntight")]),
4407            None,
4408        )
4409        .unwrap();
4410        assert!(micron.contains(">Micron without trailing newline\n"));
4411        assert!(!micron.contains(">Micron without trailing newline<"));
4412
4413        let micron = render_page(
4414            PATH_REPO,
4415            &config,
4416            &access,
4417            &page_request(&[("var_g", "public"), ("var_r", "micronblank")]),
4418            None,
4419        )
4420        .unwrap();
4421        assert!(micron.contains(">Micron with trailing blanks\n"));
4422        assert!(!micron.contains(">Micron with trailing blanks\n\n\n"));
4423    }
4424
4425    #[test]
4426    fn work_pages_render_documents_comments_and_repo_links() {
4427        let tmp = tempfile::tempdir().unwrap();
4428        let config = cfg(tmp.path());
4429        let repo_path = create_repo(
4430            config.repositories_dir.join("public/worked"),
4431            "README.md",
4432            "# Worked\n",
4433        );
4434        let work_path = crate::work::work_sidecar_path(&repo_path);
4435        let created = crate::work::create_document(
4436            &work_path,
4437            crate::work::WorkInput {
4438                title: "Active task".into(),
4439                content: "# Active Body\n".into(),
4440                format: "markdown".into(),
4441                signature: None,
4442                identity: None,
4443                author: [0x11; 16],
4444            },
4445        )
4446        .unwrap();
4447        crate::work::add_comment(
4448            &work_path,
4449            crate::work::WorkScope::Active,
4450            created.id,
4451            crate::work::WorkCommentInput {
4452                content: "Progress update".into(),
4453                format: "markdown".into(),
4454                signature: None,
4455                author: [0x22; 16],
4456            },
4457        )
4458        .unwrap();
4459        let completed = crate::work::create_document(
4460            &work_path,
4461            crate::work::WorkInput {
4462                title: "Completed task".into(),
4463                content: "Done".into(),
4464                format: "micron".into(),
4465                signature: None,
4466                identity: None,
4467                author: [0x11; 16],
4468            },
4469        )
4470        .unwrap();
4471        crate::work::complete_document(&work_path, completed.id, &[0x11; 16]).unwrap();
4472        let access = access(&config);
4473
4474        let repo = render_page(
4475            PATH_REPO,
4476            &config,
4477            &access,
4478            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4479            None,
4480        )
4481        .unwrap();
4482        assert!(repo.contains("Work (2)"));
4483        assert!(repo.contains(PATH_WORK));
4484
4485        let active = render_page(
4486            PATH_WORK,
4487            &config,
4488            &access,
4489            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4490            None,
4491        )
4492        .unwrap();
4493        assert!(active.contains(">Active Work Documents (1)"));
4494        assert!(active.contains("#1 Active task"));
4495        assert!(active.contains("1 updates"));
4496        assert!(!active.contains("Completed task"));
4497
4498        let completed_page = render_page(
4499            PATH_WORK,
4500            &config,
4501            &access,
4502            &page_request(&[
4503                ("var_g", "public"),
4504                ("var_r", "worked"),
4505                ("var_scope", "completed"),
4506            ]),
4507            None,
4508        )
4509        .unwrap();
4510        assert!(completed_page.contains(">Completed Work Documents (1)"));
4511        assert!(completed_page.contains("Completed task"));
4512
4513        let doc = render_page(
4514            PATH_WORK_DOC,
4515            &config,
4516            &access,
4517            &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
4518            None,
4519        )
4520        .unwrap();
4521        assert!(doc.contains(">Active task"));
4522        assert!(doc.contains(">Active Body"));
4523        assert!(doc.contains(">Updates (1)"));
4524        assert!(doc.contains("Progress update"));
4525        assert!(doc.contains("`[Download`:/file/workdoc`g=public|r=worked|scope=active|id=1]"));
4526
4527        let completed_doc = render_page(
4528            PATH_WORK_DOC,
4529            &config,
4530            &access,
4531            &page_request(&[
4532                ("var_g", "public"),
4533                ("var_r", "worked"),
4534                ("var_id", &completed.id.to_string()),
4535            ]),
4536            None,
4537        )
4538        .unwrap();
4539        assert!(completed_doc.contains(">Completed task"));
4540        assert!(completed_doc.contains("Status: completed"));
4541        assert!(completed_doc.contains("`[work`:/page/work.mu`g=public|r=worked|scope=completed]"));
4542        assert!(completed_doc.contains(&format!(
4543            "`[Download`:/file/workdoc`g=public|r=worked|scope=completed|id={}]",
4544            completed.id
4545        )));
4546
4547        let active_download = download_work_document(
4548            &config,
4549            &access,
4550            &page_request(&[
4551                ("var_g", "public"),
4552                ("var_r", "worked"),
4553                ("var_scope", "active"),
4554                ("var_id", "1"),
4555            ]),
4556            None,
4557        )
4558        .unwrap();
4559        assert_resource_response(active_download, b"# Active Body");
4560
4561        let completed_download = download_work_document(
4562            &config,
4563            &access,
4564            &page_request(&[
4565                ("var_g", "public"),
4566                ("var_r", "worked"),
4567                ("var_id", &completed.id.to_string()),
4568            ]),
4569            None,
4570        )
4571        .unwrap();
4572        assert_resource_response(completed_download, b"Done");
4573    }
4574
4575    #[test]
4576    fn work_pages_respect_read_access() {
4577        let tmp = tempfile::tempdir().unwrap();
4578        let mut config = cfg(tmp.path());
4579        create_repo(
4580            config.repositories_dir.join("public/private-work"),
4581            "README.md",
4582            "# Private\n",
4583        );
4584        config.allow_read = vec!["none".into()];
4585        let access = access(&config);
4586
4587        assert!(render_page(
4588            PATH_WORK,
4589            &config,
4590            &access,
4591            &page_request(&[("var_g", "public"), ("var_r", "private-work")]),
4592            None,
4593        )
4594        .is_err());
4595    }
4596
4597    fn cfg(root: &std::path::Path) -> ServerConfig {
4598        ServerConfig {
4599            dir: root.to_path_buf(),
4600            reticulum_dir: None,
4601            repositories_dir: root.join("repositories"),
4602            identity_path: root.join("repositories_identity"),
4603            client_identity_path: root.join("client_identity"),
4604            node_name: "Test Git Node".into(),
4605            announce_interval_secs: 300,
4606            serve_nomadnet: true,
4607            templates_dir: root.join("templates"),
4608            unicode_icons: false,
4609            record_stats: false,
4610            stats_ignore_identities: Vec::new(),
4611            stats_push_ignore_identities: Vec::new(),
4612            blocked_identities: Vec::new(),
4613            identity_aliases: std::collections::BTreeMap::new(),
4614            allow_read: vec!["all".into()],
4615            allow_write: vec!["none".into()],
4616            allow_create: vec!["none".into()],
4617            allow_stats: vec!["none".into()],
4618            allow_release: vec!["none".into()],
4619            allow_interact: vec!["none".into()],
4620            allow_propose: vec!["none".into()],
4621            allow_admin: vec!["none".into()],
4622            log_level: logging::DEFAULT_LOG_LEVEL,
4623        }
4624    }
4625
4626    fn access(config: &ServerConfig) -> Access {
4627        Access::new(
4628            &config.allow_read,
4629            &config.allow_write,
4630            &config.allow_create,
4631            &config.allow_stats,
4632            &config.allow_release,
4633            &config.allow_interact,
4634            &config.allow_admin,
4635            config.repositories_dir.clone(),
4636        )
4637        .unwrap()
4638    }
4639
4640    fn page_request(fields: &[(&str, &str)]) -> Vec<u8> {
4641        msgpack::pack(&Value::Map(
4642            fields
4643                .iter()
4644                .map(|(k, v)| (Value::Str((*k).into()), Value::Str((*v).into())))
4645                .collect(),
4646        ))
4647    }
4648
4649    fn assert_resource_response(response: RequestResponse, expected: &[u8]) {
4650        match response {
4651            RequestResponse::Resource { data, metadata, .. } => {
4652                assert_eq!(data, expected);
4653                assert_eq!(
4654                    metadata.as_deref(),
4655                    Some(&protocol::metadata_status(protocol::RES_OK)[..])
4656                );
4657            }
4658            RequestResponse::Bytes(bytes) => panic!("expected resource response, got {bytes:?}"),
4659        }
4660    }
4661
4662    fn create_repo(path: std::path::PathBuf, file: &str, content: &str) -> std::path::PathBuf {
4663        create_repo_bytes(path, file, content.as_bytes())
4664    }
4665
4666    fn create_repo_bytes(
4667        path: std::path::PathBuf,
4668        file: &str,
4669        content: &[u8],
4670    ) -> std::path::PathBuf {
4671        let work = path.with_extension("work");
4672        fs::create_dir_all(&work).unwrap();
4673        run_git(Command::new("git").arg("init").arg(&work));
4674        let file_path = work.join(file);
4675        fs::create_dir_all(file_path.parent().unwrap()).unwrap();
4676        fs::write(&file_path, content).unwrap();
4677        run_git(Command::new("git").arg("-C").arg(&work).arg("add").arg("."));
4678        run_git(
4679            Command::new("git")
4680                .arg("-C")
4681                .arg(&work)
4682                .arg("-c")
4683                .arg("user.name=RNS Page Test")
4684                .arg("-c")
4685                .arg("user.email=rns-page-test@example.invalid")
4686                .arg("commit")
4687                .arg("-m")
4688                .arg("initial"),
4689        );
4690        run_git(
4691            Command::new("git")
4692                .arg("-C")
4693                .arg(&work)
4694                .args(["branch", "-M", "main"]),
4695        );
4696        run_git(
4697            Command::new("git")
4698                .arg("clone")
4699                .arg("--bare")
4700                .arg(&work)
4701                .arg(&path),
4702        );
4703        path
4704    }
4705
4706    fn run_git(cmd: &mut Command) -> String {
4707        let output = cmd.output().unwrap();
4708        assert!(
4709            output.status.success(),
4710            "git command failed: {}",
4711            String::from_utf8_lossy(&output.stderr)
4712        );
4713        String::from_utf8_lossy(&output.stdout).into_owned()
4714    }
4715}