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.trim_start_matches(['.', '/'])
1992        .split('/')
1993        .filter(|component| *component != ".")
1994        .collect::<Vec<_>>()
1995        .join("/")
1996}
1997
1998fn validate_refish(value: &str) -> Result<()> {
1999    if value.is_empty()
2000        || value.starts_with('-')
2001        || value.contains("..")
2002        || value.contains('\\')
2003        || value.contains(' ')
2004        || value.contains('~')
2005        || value.contains('^')
2006        || value.contains(':')
2007    {
2008        Err(Error::msg("invalid git ref"))
2009    } else {
2010        Ok(())
2011    }
2012}
2013
2014fn repository_description(repo: &Path) -> Result<String> {
2015    let path = repo.join("description");
2016    if !path.exists() {
2017        return Ok(String::new());
2018    }
2019    Ok(fs::read_to_string(path)?.trim().to_string())
2020}
2021
2022#[derive(Debug, Clone, PartialEq, Eq)]
2023struct RepositoryOrigin {
2024    kind: RepositoryOriginKind,
2025    source: String,
2026    synced_at: Option<u64>,
2027}
2028
2029#[derive(Debug, Clone, PartialEq, Eq)]
2030enum RepositoryOriginKind {
2031    Fork,
2032    Mirror,
2033}
2034
2035impl RepositoryOrigin {
2036    fn label(&self) -> &'static str {
2037        match self.kind {
2038            RepositoryOriginKind::Fork => "Forked",
2039            RepositoryOriginKind::Mirror => "Mirrored",
2040        }
2041    }
2042}
2043
2044fn repository_origin(repo: &Path) -> Result<Option<RepositoryOrigin>> {
2045    let kind_output = run_git_output(
2046        Command::new("git")
2047            .arg("--git-dir")
2048            .arg(repo)
2049            .arg("config")
2050            .arg("--get")
2051            .arg("repository.rngit.type"),
2052        GIT_COMMAND_TIMEOUT,
2053    )?;
2054    if !kind_output.status.success() {
2055        return Ok(None);
2056    }
2057    let kind = match String::from_utf8_lossy(&kind_output.stdout).trim() {
2058        "fork" => RepositoryOriginKind::Fork,
2059        "mirror" => RepositoryOriginKind::Mirror,
2060        _ => return Ok(None),
2061    };
2062    let source_output = run_git_output(
2063        Command::new("git")
2064            .arg("--git-dir")
2065            .arg(repo)
2066            .arg("config")
2067            .arg("--get")
2068            .arg("repository.rngit.upstream.source"),
2069        GIT_COMMAND_TIMEOUT,
2070    )?;
2071    if !source_output.status.success() {
2072        return Ok(None);
2073    }
2074    let source = String::from_utf8_lossy(&source_output.stdout)
2075        .trim()
2076        .to_string();
2077    if source.is_empty() {
2078        return Ok(None);
2079    }
2080    let synced_at = run_git_output(
2081        Command::new("git")
2082            .arg("--git-dir")
2083            .arg(repo)
2084            .arg("config")
2085            .arg("--get")
2086            .arg("repository.rngit.upstream.sync"),
2087        GIT_COMMAND_TIMEOUT,
2088    )
2089    .ok()
2090    .and_then(|output| output.status.success().then_some(output))
2091    .and_then(|output| {
2092        String::from_utf8_lossy(&output.stdout)
2093            .trim()
2094            .parse::<u64>()
2095            .ok()
2096    });
2097
2098    Ok(Some(RepositoryOrigin {
2099        kind,
2100        source,
2101        synced_at,
2102    }))
2103}
2104
2105fn format_sync_age(synced_at: u64) -> String {
2106    let now = SystemTime::now()
2107        .duration_since(UNIX_EPOCH)
2108        .unwrap_or_default()
2109        .as_secs();
2110    let seconds = now.saturating_sub(synced_at);
2111    if seconds < 60 {
2112        format!("{seconds}s")
2113    } else if seconds < 3_600 {
2114        format!("{}m", seconds / 60)
2115    } else if seconds < 86_400 {
2116        format!("{}h", seconds / 3_600)
2117    } else {
2118        format!("{}d", seconds / 86_400)
2119    }
2120}
2121
2122fn repository_thanks(repo: &Path, add: bool) -> Result<u64> {
2123    let path = repo.with_extension("thanks");
2124    let mut count = if path.exists() {
2125        fs::read_to_string(&path)?
2126            .trim()
2127            .parse::<u64>()
2128            .unwrap_or(0)
2129    } else {
2130        0
2131    };
2132    if add {
2133        count = count.saturating_add(1);
2134        fs::write(path, format!("{count}\n"))?;
2135    }
2136    Ok(count)
2137}
2138
2139#[derive(Debug)]
2140struct ReadmeContent {
2141    content: String,
2142    markdown: bool,
2143}
2144
2145fn readme_content(repo: &Path) -> Result<Option<ReadmeContent>> {
2146    const NAMES: &[&str] = &[
2147        "README.mu",
2148        "Readme.mu",
2149        "readme.mu",
2150        "README",
2151        "readme",
2152        "README.md",
2153        "readme.md",
2154        "README.rst",
2155        "README.txt",
2156        "readme.rst",
2157        "readme.txt",
2158    ];
2159    for name in NAMES {
2160        match run_git_output(
2161            Command::new("git")
2162                .arg("--git-dir")
2163                .arg(repo)
2164                .arg("show")
2165                .arg(format!("HEAD:{name}")),
2166            GIT_COMMAND_TIMEOUT,
2167        ) {
2168            Ok(output) if output.status.success() => {
2169                let content = String::from_utf8_lossy(&output.stdout).into_owned();
2170                let markdown = name.ends_with(".md");
2171                let content = if markdown || name.ends_with(".mu") {
2172                    content
2173                } else {
2174                    m_escape(&content)
2175                };
2176                return Ok(Some(ReadmeContent { content, markdown }));
2177            }
2178            Ok(_) => {}
2179            Err(err) => {
2180                log::warn!("Git command execution failed while reading README: {err}");
2181            }
2182        }
2183    }
2184    Ok(None)
2185}
2186
2187fn markdown_to_micron(input: &str) -> String {
2188    markdown_to_micron_scoped(input, None)
2189}
2190
2191fn markdown_to_micron_scoped(input: &str, url_scope: Option<&str>) -> String {
2192    let lines: Vec<&str> = input.lines().collect();
2193    let mut out = String::new();
2194    let mut code_block: Option<(Option<String>, String)> = None;
2195    let mut index = 0;
2196
2197    while index < lines.len() {
2198        let line = lines[index];
2199        let trimmed = line.trim();
2200        if trimmed.starts_with("```") {
2201            if let Some((language, content)) = code_block.take() {
2202                out.push_str(&markdown_code_block_to_micron(
2203                    language.as_deref(),
2204                    &content,
2205                ));
2206            } else {
2207                code_block = Some((markdown_fence_language(trimmed), String::new()));
2208            }
2209            index += 1;
2210            continue;
2211        }
2212        if let Some((_, content)) = code_block.as_mut() {
2213            content.push_str(line);
2214            content.push('\n');
2215            index += 1;
2216            continue;
2217        }
2218        if let Some(quote) = markdown_quote(line) {
2219            let mut parts = vec![quote.to_string()];
2220            index += 1;
2221            while index < lines.len() {
2222                if let Some(quote) = markdown_quote(lines[index]) {
2223                    parts.push(quote.to_string());
2224                    index += 1;
2225                } else {
2226                    break;
2227                }
2228            }
2229            let formatted = format_markdown_inline_scoped(&parts.join(" "), url_scope);
2230            for wrapped in markdown_wrap_text(&formatted, 77) {
2231                out.push_str(" │ ");
2232                out.push_str(&wrapped);
2233                out.push('\n');
2234            }
2235            continue;
2236        }
2237        if is_table_start(&lines, index) {
2238            let mut table = Vec::new();
2239            while index < lines.len() && lines[index].contains('|') {
2240                table.push(lines[index]);
2241                index += 1;
2242            }
2243            out.push_str(&format_markdown_table(&table, url_scope));
2244            continue;
2245        }
2246        if let Some((level, text)) = markdown_heading(line) {
2247            out.push_str(&">".repeat(level));
2248            out.push_str(&format_markdown_inline_scoped(text.trim(), url_scope));
2249            out.push('\n');
2250        } else if is_markdown_rule(trimmed) {
2251            out.push_str("-\n");
2252        } else if let Some((indent, text)) = unordered_list_item(line) {
2253            out.push_str(indent);
2254            out.push_str(" • ");
2255            out.push_str(&format_markdown_inline_scoped(text, url_scope));
2256            out.push('\n');
2257        } else if let Some((indent, number, text)) = ordered_list_item(line) {
2258            out.push_str(indent);
2259            out.push_str(number);
2260            out.push_str(". ");
2261            out.push_str(&format_markdown_inline_scoped(text, url_scope));
2262            out.push('\n');
2263        } else {
2264            out.push_str(&format_markdown_line(line, url_scope));
2265            out.push('\n');
2266        }
2267        index += 1;
2268    }
2269    if let Some((language, content)) = code_block {
2270        out.push_str(&markdown_code_block_to_micron(
2271            language.as_deref(),
2272            &content,
2273        ));
2274    }
2275
2276    out
2277}
2278
2279fn markdown_code_block_to_micron(language: Option<&str>, content: &str) -> String {
2280    if language.is_some_and(|language| language.eq_ignore_ascii_case("rawmu")) {
2281        return content.to_string();
2282    }
2283    crate::highlight::literal_block(content, None, language)
2284}
2285
2286fn markdown_fence_language(trimmed: &str) -> Option<String> {
2287    trimmed
2288        .strip_prefix("```")
2289        .and_then(|info| info.split_whitespace().next())
2290        .filter(|language| !language.is_empty())
2291        .map(str::to_string)
2292}
2293
2294fn markdown_quote(line: &str) -> Option<&str> {
2295    line.strip_prefix('>')
2296        .map(|quote| quote.strip_prefix(' ').unwrap_or(quote))
2297}
2298
2299fn markdown_heading(line: &str) -> Option<(usize, &str)> {
2300    let level = line.chars().take_while(|ch| *ch == '#').count();
2301    if (1..=6).contains(&level) && line.as_bytes().get(level) == Some(&b' ') {
2302        Some((level, &line[level + 1..]))
2303    } else {
2304        None
2305    }
2306}
2307
2308fn is_markdown_rule(trimmed: &str) -> bool {
2309    trimmed.len() >= 3
2310        && ["-", "*", "_", "="]
2311            .iter()
2312            .any(|marker| trimmed.chars().all(|ch| ch.to_string() == *marker))
2313}
2314
2315fn unordered_list_item(line: &str) -> Option<(&str, &str)> {
2316    let trimmed = line.trim_start();
2317    let indent = &line[..line.len() - trimmed.len()];
2318    for marker in ["- ", "* ", "+ "] {
2319        if let Some(text) = trimmed.strip_prefix(marker) {
2320            return Some((indent, text));
2321        }
2322    }
2323    None
2324}
2325
2326fn ordered_list_item(line: &str) -> Option<(&str, &str, &str)> {
2327    let trimmed = line.trim_start();
2328    let indent = &line[..line.len() - trimmed.len()];
2329    let Some((number, text)) = trimmed.split_once(". ") else {
2330        return None;
2331    };
2332    if !number.is_empty() && number.chars().all(|ch| ch.is_ascii_digit()) {
2333        Some((indent, number, text))
2334    } else {
2335        None
2336    }
2337}
2338
2339fn format_markdown_line(line: &str, url_scope: Option<&str>) -> String {
2340    let formatted = format_markdown_inline_scoped(line, url_scope);
2341    if (line.starts_with('-') && !line.starts_with("---") && !line.starts_with("- "))
2342        || line.starts_with('<')
2343    {
2344        format!("\\{formatted}")
2345    } else {
2346        formatted
2347    }
2348}
2349
2350fn is_table_start(lines: &[&str], index: usize) -> bool {
2351    index + 1 < lines.len()
2352        && lines[index].contains('|')
2353        && lines[index + 1].contains('|')
2354        && is_table_separator(lines[index + 1])
2355}
2356
2357fn is_table_separator(line: &str) -> bool {
2358    parse_table_row(line)
2359        .iter()
2360        .all(|cell| !cell.is_empty() && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
2361}
2362
2363#[derive(Clone, Copy)]
2364enum TableAlign {
2365    Left,
2366    Right,
2367    Center,
2368}
2369
2370fn format_markdown_table(lines: &[&str], url_scope: Option<&str>) -> String {
2371    if lines.len() < 2 {
2372        return String::new();
2373    }
2374    let header: Vec<String> = parse_table_row(lines[0])
2375        .into_iter()
2376        .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2377        .collect();
2378    if header.is_empty() {
2379        return String::new();
2380    }
2381    let columns = header.len();
2382    let mut alignments = parse_table_alignments(lines[1]);
2383    while alignments.len() < columns {
2384        alignments.push(TableAlign::Left);
2385    }
2386    alignments.truncate(columns);
2387
2388    let rows: Vec<Vec<String>> = lines
2389        .iter()
2390        .skip(2)
2391        .map(|line| {
2392            let mut row: Vec<String> = parse_table_row(line)
2393                .into_iter()
2394                .map(|cell| format_markdown_inline_scoped(cell.trim(), url_scope))
2395                .collect();
2396            while row.len() < columns {
2397                row.push(String::new());
2398            }
2399            row.truncate(columns);
2400            row
2401        })
2402        .collect();
2403
2404    let mut widths = vec![3; columns];
2405    for (index, cell) in header.iter().enumerate() {
2406        widths[index] = widths[index].max(markdown_visible_width(cell));
2407    }
2408    for row in &rows {
2409        for (index, cell) in row.iter().enumerate().take(columns) {
2410            widths[index] = widths[index].max(markdown_visible_width(cell));
2411        }
2412    }
2413
2414    let mut out = String::new();
2415    out.push_str(&table_border('┌', '┬', '┐', &widths));
2416    out.push_str(&table_row(
2417        &header,
2418        &widths,
2419        &vec![TableAlign::Left; columns],
2420    ));
2421    out.push_str(&table_border('├', '┼', '┤', &widths));
2422    for row in rows {
2423        out.push_str(&table_row(&row, &widths, &alignments));
2424    }
2425    out.push_str(&table_border('└', '┴', '┘', &widths));
2426    out
2427}
2428
2429fn parse_table_row(line: &str) -> Vec<String> {
2430    let mut trimmed = line.trim();
2431    if let Some(rest) = trimmed.strip_prefix('|') {
2432        trimmed = rest;
2433    }
2434    if let Some(rest) = trimmed.strip_suffix('|') {
2435        trimmed = rest;
2436    }
2437    let mut cells = Vec::new();
2438    let mut current = String::new();
2439    let mut escaped = false;
2440    for ch in trimmed.chars() {
2441        if escaped {
2442            current.push(ch);
2443            escaped = false;
2444        } else if ch == '\\' {
2445            escaped = true;
2446        } else if ch == '|' {
2447            cells.push(current.trim().to_string());
2448            current.clear();
2449        } else {
2450            current.push(ch);
2451        }
2452    }
2453    if escaped {
2454        current.push('\\');
2455    }
2456    cells.push(current.trim().to_string());
2457    cells
2458}
2459
2460fn parse_table_alignments(line: &str) -> Vec<TableAlign> {
2461    parse_table_row(line)
2462        .into_iter()
2463        .map(|cell| {
2464            let cell = cell.trim();
2465            if cell.starts_with(':') && cell.ends_with(':') {
2466                TableAlign::Center
2467            } else if cell.ends_with(':') {
2468                TableAlign::Right
2469            } else {
2470                TableAlign::Left
2471            }
2472        })
2473        .collect()
2474}
2475
2476fn table_border(left: char, middle: char, right: char, widths: &[usize]) -> String {
2477    let mut out = String::new();
2478    out.push(left);
2479    for (index, width) in widths.iter().enumerate() {
2480        out.push_str(&"─".repeat(width + 2));
2481        if index + 1 == widths.len() {
2482            out.push(right);
2483        } else {
2484            out.push(middle);
2485        }
2486    }
2487    out.push('\n');
2488    out
2489}
2490
2491fn table_row(cells: &[String], widths: &[usize], alignments: &[TableAlign]) -> String {
2492    let mut out = String::from("│");
2493    for (index, width) in widths.iter().enumerate() {
2494        let cell = cells.get(index).map(String::as_str).unwrap_or("");
2495        out.push(' ');
2496        out.push_str(&pad_table_cell(
2497            cell,
2498            *width,
2499            alignments.get(index).copied().unwrap_or(TableAlign::Left),
2500        ));
2501        out.push(' ');
2502        out.push('│');
2503    }
2504    out.push('\n');
2505    out
2506}
2507
2508fn pad_table_cell(cell: &str, width: usize, alignment: TableAlign) -> String {
2509    let visible = markdown_visible_width(cell);
2510    let padding = width.saturating_sub(visible);
2511    match alignment {
2512        TableAlign::Left => format!("{cell}{}", " ".repeat(padding)),
2513        TableAlign::Right => format!("{}{cell}", " ".repeat(padding)),
2514        TableAlign::Center => {
2515            let left = padding / 2;
2516            let right = padding - left;
2517            format!("{}{cell}{}", " ".repeat(left), " ".repeat(right))
2518        }
2519    }
2520}
2521
2522fn markdown_visible_width(value: &str) -> usize {
2523    let mut width = 0;
2524    let mut chars = value.chars().peekable();
2525    while let Some(ch) = chars.next() {
2526        if ch == '`' {
2527            if matches!(chars.peek(), Some('F') | Some('B')) {
2528                chars.next();
2529                if matches!(chars.peek(), Some('T')) {
2530                    chars.next();
2531                    for _ in 0..6 {
2532                        chars.next();
2533                    }
2534                } else {
2535                    for _ in 0..3 {
2536                        chars.next();
2537                    }
2538                }
2539                continue;
2540            }
2541            if matches!(chars.peek(), Some('!') | Some('*') | Some('_') | Some('=')) {
2542                chars.next();
2543                continue;
2544            }
2545            if matches!(chars.peek(), Some('f') | Some('b') | Some('a')) {
2546                chars.next();
2547                continue;
2548            }
2549            if matches!(chars.peek(), Some('[')) {
2550                let mut label_width = 0;
2551                for link_ch in chars.by_ref() {
2552                    if link_ch == '`' {
2553                        for target_ch in chars.by_ref() {
2554                            if target_ch == ']' {
2555                                break;
2556                            }
2557                        }
2558                        break;
2559                    }
2560                    label_width += 1;
2561                }
2562                width += label_width;
2563                continue;
2564            }
2565        }
2566        width += 1;
2567    }
2568    width
2569}
2570
2571fn markdown_wrap_text(text: &str, width: usize) -> Vec<String> {
2572    if text.is_empty() {
2573        return vec![String::new()];
2574    }
2575    let mut lines = Vec::new();
2576    let mut current = String::new();
2577    let mut current_width = 0;
2578    for word in text.split_whitespace() {
2579        let word_width = markdown_visible_width(word);
2580        if !current.is_empty() && current_width + 1 + word_width > width {
2581            lines.push(current);
2582            current = String::new();
2583            current_width = 0;
2584        }
2585        if !current.is_empty() {
2586            current.push(' ');
2587            current_width += 1;
2588        }
2589        current.push_str(word);
2590        current_width += word_width;
2591    }
2592    if !current.is_empty() {
2593        lines.push(current);
2594    }
2595    if lines.is_empty() {
2596        lines.push(String::new());
2597    }
2598    lines
2599}
2600
2601#[derive(Debug)]
2602enum InlineToken {
2603    Link { label: String, target: String },
2604    Code(String),
2605}
2606
2607#[derive(Debug, Clone, Copy)]
2608struct MarkdownLinkStyle<'a> {
2609    bold: bool,
2610    underline: bool,
2611    color: Option<&'a str>,
2612}
2613
2614impl Default for MarkdownLinkStyle<'_> {
2615    fn default() -> Self {
2616        Self {
2617            bold: true,
2618            underline: true,
2619            color: None,
2620        }
2621    }
2622}
2623
2624fn format_markdown_inline_scoped(input: &str, url_scope: Option<&str>) -> String {
2625    let (with_placeholders, tokens) = extract_markdown_inline_tokens(input);
2626    let escaped = m_escape(&with_placeholders);
2627    let styled = apply_markdown_style(&escaped, "**", "`!", "`!");
2628    let styled = apply_markdown_style(&styled, "__", "`!", "`!");
2629    let styled = apply_markdown_style(&styled, "*", "`*", "`*");
2630    let styled = apply_markdown_style(&styled, "_", "`*", "`*");
2631    restore_markdown_inline_tokens(&styled, &tokens, url_scope)
2632}
2633
2634fn extract_markdown_inline_tokens(input: &str) -> (String, Vec<InlineToken>) {
2635    let mut out = String::new();
2636    let mut tokens = Vec::new();
2637    let mut index = 0;
2638
2639    while index < input.len() {
2640        let rest = &input[index..];
2641        if rest.starts_with('`') {
2642            if let Some(end) = rest[1..].find('`') {
2643                let content = &rest[1..1 + end];
2644                if is_markdown_inline_code(content) {
2645                    tokens.push(InlineToken::Code(content.to_string()));
2646                    out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2647                    index += end + 2;
2648                    continue;
2649                }
2650            }
2651        }
2652        if rest.starts_with('[') {
2653            if let Some((consumed, label, target)) = parse_markdown_link(rest) {
2654                tokens.push(InlineToken::Link { label, target });
2655                out.push_str(&markdown_token_placeholder(tokens.len() - 1));
2656                index += consumed;
2657                continue;
2658            }
2659        }
2660        let Some(ch) = rest.chars().next() else {
2661            break;
2662        };
2663        out.push(ch);
2664        index += ch.len_utf8();
2665    }
2666
2667    (out, tokens)
2668}
2669
2670fn is_markdown_inline_code(content: &str) -> bool {
2671    !content.is_empty()
2672        && !content
2673            .chars()
2674            .next()
2675            .is_some_and(|ch| matches!(ch, '!' | '*' | '_' | '=' | 'F' | 'B' | 'f' | 'b' | 'a'))
2676}
2677
2678fn parse_markdown_link(input: &str) -> Option<(usize, String, String)> {
2679    let label_end = input[1..].find(']')? + 1;
2680    if !input[label_end + 1..].starts_with('(') {
2681        return None;
2682    }
2683    let target_start = label_end + 2;
2684    let target_end = input[target_start..].find(')')? + target_start;
2685    Some((
2686        target_end + 1,
2687        input[1..label_end].replace('`', ""),
2688        input[target_start..target_end].to_string(),
2689    ))
2690}
2691
2692fn markdown_token_placeholder(index: usize) -> String {
2693    format!("\u{1f}{index}\u{1f}")
2694}
2695
2696fn restore_markdown_inline_tokens(
2697    input: &str,
2698    tokens: &[InlineToken],
2699    url_scope: Option<&str>,
2700) -> String {
2701    let mut out = input.to_string();
2702    for (index, token) in tokens.iter().enumerate() {
2703        let replacement = match token {
2704            InlineToken::Link { label, target } => {
2705                format_markdown_link(label, target, url_scope, MarkdownLinkStyle::default())
2706            }
2707            InlineToken::Code(value) => format!("`BT383838`Fddd{}`f`b", m_escape(value)),
2708        };
2709        out = out.replace(&markdown_token_placeholder(index), &replacement);
2710    }
2711    out
2712}
2713
2714fn format_markdown_link(
2715    label: &str,
2716    target: &str,
2717    url_scope: Option<&str>,
2718    style: MarkdownLinkStyle<'_>,
2719) -> String {
2720    let mut link = String::new();
2721    if style.underline {
2722        link.push_str("`_");
2723    }
2724    if style.bold {
2725        link.push_str("`!");
2726    }
2727    link.push_str("`[");
2728    link.push_str(sanitize_label(label).trim());
2729    link.push('`');
2730    link.push_str(&markdown_link_target(target, url_scope));
2731    link.push(']');
2732    if style.bold {
2733        link.push_str("`!");
2734    }
2735    if style.underline {
2736        link.push_str("`_");
2737    }
2738
2739    match style.color {
2740        Some(color) if color.len() == 3 => format!("`F{color}{link}`f"),
2741        Some(color) if color.len() == 6 => format!("`FT{color}{link}`f"),
2742        _ => link,
2743    }
2744}
2745
2746fn markdown_link_target(value: &str, url_scope: Option<&str>) -> String {
2747    if let Some(scope) = url_scope {
2748        if !value.contains(":/") {
2749            let (path, anchor) = value
2750                .split_once('#')
2751                .map(|(path, anchor)| (path, Some(anchor)))
2752                .unwrap_or((value, None));
2753            let mut out = format!("{scope}{}", sanitize_field(path));
2754            if let Some(anchor) = anchor.filter(|anchor| !anchor.is_empty()) {
2755                out.push_str("|anchor=");
2756                out.push_str(&sanitize_field(anchor));
2757            }
2758            return out;
2759        }
2760    }
2761    sanitize_markdown_link_target(value)
2762}
2763
2764fn sanitize_markdown_link_target(value: &str) -> String {
2765    value
2766        .chars()
2767        .filter(|ch| *ch != '[' && *ch != ']' && *ch != '`')
2768        .collect()
2769}
2770
2771fn apply_markdown_style(input: &str, marker: &str, open: &str, close: &str) -> String {
2772    let mut out = String::new();
2773    let mut rest = input;
2774
2775    while let Some(start) = rest.find(marker) {
2776        let content_start = start + marker.len();
2777        let Some(end) = rest[content_start..].find(marker) else {
2778            out.push_str(rest);
2779            return out;
2780        };
2781        let content_end = content_start + end;
2782        out.push_str(&rest[..start]);
2783        out.push_str(open);
2784        out.push_str(&rest[content_start..content_end]);
2785        out.push_str(close);
2786        rest = &rest[content_end + marker.len()..];
2787    }
2788    out.push_str(rest);
2789    out
2790}
2791
2792#[derive(Debug)]
2793struct Refs {
2794    heads: Vec<RefInfo>,
2795    tags: Vec<RefInfo>,
2796}
2797
2798#[derive(Debug)]
2799struct RefInfo {
2800    sha: String,
2801    name: String,
2802}
2803
2804fn git_refs(repo: &Path) -> Result<Refs> {
2805    let output = run_git(
2806        Command::new("git")
2807            .arg("--git-dir")
2808            .arg(repo)
2809            .args(["for-each-ref", "--format=%(objectname) %(refname)"]),
2810    )?;
2811    let mut refs = Refs {
2812        heads: Vec::new(),
2813        tags: Vec::new(),
2814    };
2815    for line in output.lines() {
2816        let Some((sha, name)) = line.split_once(' ') else {
2817            continue;
2818        };
2819        if let Some(short) = name.strip_prefix("refs/heads/") {
2820            refs.heads.push(RefInfo {
2821                sha: sha.to_string(),
2822                name: short.to_string(),
2823            });
2824        } else if let Some(short) = name.strip_prefix("refs/tags/") {
2825            refs.tags.push(RefInfo {
2826                sha: sha.to_string(),
2827                name: short.to_string(),
2828            });
2829        }
2830    }
2831    Ok(refs)
2832}
2833
2834fn resolve_ref(repo: &Path, reference: &str) -> Result<String> {
2835    validate_refish(reference)?;
2836    let output = run_git(
2837        Command::new("git")
2838            .arg("--git-dir")
2839            .arg(repo)
2840            .arg("rev-parse")
2841            .arg("--verify")
2842            .arg(format!("{reference}^{{commit}}")),
2843    )?;
2844    Ok(output.trim().to_string())
2845}
2846
2847#[derive(Debug)]
2848struct TreeEntry {
2849    kind: String,
2850    name: String,
2851    size: Option<u64>,
2852}
2853
2854impl TreeEntry {
2855    fn size_label(&self) -> String {
2856        self.size
2857            .map(|size| format!(" ({size} bytes)"))
2858            .unwrap_or_default()
2859    }
2860}
2861
2862fn tree_entries(repo: &Path, reference: &str, path: &str) -> Result<Vec<TreeEntry>> {
2863    validate_git_path(path)?;
2864    let spec = if path.is_empty() {
2865        reference.to_string()
2866    } else {
2867        format!("{reference}:{path}")
2868    };
2869    let output = run_git(
2870        Command::new("git")
2871            .arg("--git-dir")
2872            .arg(repo)
2873            .arg("ls-tree")
2874            .arg("-l")
2875            .arg(spec),
2876    )?;
2877    let mut entries = Vec::new();
2878    for line in output.lines() {
2879        let Some((meta, name)) = line.split_once('\t') else {
2880            continue;
2881        };
2882        let parts: Vec<&str> = meta.split_whitespace().collect();
2883        if parts.len() < 4 {
2884            continue;
2885        }
2886        let size = parts[3].parse::<u64>().ok();
2887        entries.push(TreeEntry {
2888            kind: parts[1].to_string(),
2889            name: name.to_string(),
2890            size,
2891        });
2892    }
2893    entries.sort_by_key(|entry| (entry.kind != "tree", entry.name.to_ascii_lowercase()));
2894    Ok(entries)
2895}
2896
2897fn git_object_kind(repo: &Path, reference: &str, path: &str) -> Result<String> {
2898    validate_git_path(path)?;
2899    let spec = if path.is_empty() {
2900        reference.to_string()
2901    } else {
2902        format!("{reference}:{path}")
2903    };
2904    let output = run_git(
2905        Command::new("git")
2906            .arg("--git-dir")
2907            .arg(repo)
2908            .arg("cat-file")
2909            .arg("-t")
2910            .arg(spec),
2911    )?;
2912    Ok(output.trim().to_string())
2913}
2914
2915struct BlobInfo {
2916    content: String,
2917    size: u64,
2918    binary: bool,
2919    displayable: bool,
2920}
2921
2922fn blob_info(repo: &Path, reference: &str, path: &str) -> Result<BlobInfo> {
2923    validate_git_path(path)?;
2924    let spec = format!("{reference}:{path}");
2925    let size = run_git(
2926        Command::new("git")
2927            .arg("--git-dir")
2928            .arg(repo)
2929            .arg("cat-file")
2930            .arg("-s")
2931            .arg(&spec),
2932    )?
2933    .trim()
2934    .parse::<u64>()
2935    .unwrap_or(0);
2936    if size > 256 * 1024 {
2937        return Ok(BlobInfo {
2938            content: format!("File is too large to display ({size} bytes)."),
2939            size,
2940            binary: false,
2941            displayable: false,
2942        });
2943    }
2944    let output = run_git_output(
2945        Command::new("git")
2946            .arg("--git-dir")
2947            .arg(repo)
2948            .arg("show")
2949            .arg(spec),
2950        GIT_COMMAND_TIMEOUT,
2951    )?;
2952    if !output.status.success() {
2953        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
2954    }
2955    let binary = output.stdout.contains(&0);
2956    Ok(BlobInfo {
2957        content: if binary {
2958            "Binary file is not displayed.".to_string()
2959        } else {
2960            String::from_utf8_lossy(&output.stdout).into_owned()
2961        },
2962        size,
2963        binary,
2964        displayable: !binary,
2965    })
2966}
2967
2968#[derive(Debug)]
2969struct CommitSummary {
2970    hash: String,
2971    subject: String,
2972    author: String,
2973    timestamp: i64,
2974}
2975
2976fn commits(repo: &Path, reference: &str, limit: usize) -> Result<Vec<CommitSummary>> {
2977    let format = "%H%x1f%s%x1f%an%x1f%at";
2978    let output = run_git(
2979        Command::new("git")
2980            .arg("--git-dir")
2981            .arg(repo)
2982            .arg("log")
2983            .arg(format!("--format={format}"))
2984            .arg("-n")
2985            .arg(limit.to_string())
2986            .arg(reference),
2987    )?;
2988    let mut commits = Vec::new();
2989    for line in output.lines() {
2990        let parts: Vec<&str> = line.split('\x1f').collect();
2991        if parts.len() == 4 {
2992            commits.push(CommitSummary {
2993                hash: parts[0].to_string(),
2994                subject: parts[1].to_string(),
2995                author: parts[2].to_string(),
2996                timestamp: parts[3].parse().unwrap_or_default(),
2997            });
2998        }
2999    }
3000    Ok(commits)
3001}
3002
3003#[derive(Debug)]
3004struct CommitInfo {
3005    hash: String,
3006    subject: String,
3007    body: String,
3008    author: String,
3009    email: String,
3010    timestamp: i64,
3011    files: Vec<CommitFile>,
3012}
3013
3014#[derive(Debug)]
3015struct CommitFile {
3016    status: String,
3017    path: String,
3018}
3019
3020fn commit_info(repo: &Path, hash: &str) -> Result<CommitInfo> {
3021    validate_refish(hash)?;
3022    let output = run_git(
3023        Command::new("git")
3024            .arg("--git-dir")
3025            .arg(repo)
3026            .arg("show")
3027            .arg("--no-patch")
3028            .arg("--format=%H%x1f%s%x1f%an%x1f%ae%x1f%at%x1f%B")
3029            .arg(hash),
3030    )?;
3031    let parts: Vec<&str> = output.splitn(6, '\x1f').collect();
3032    if parts.len() != 6 {
3033        return Err(Error::msg("invalid commit output"));
3034    }
3035    let files_output = run_git(
3036        Command::new("git")
3037            .arg("--git-dir")
3038            .arg(repo)
3039            .arg("diff-tree")
3040            .arg("--root")
3041            .arg("--no-commit-id")
3042            .arg("--name-status")
3043            .arg("-r")
3044            .arg(hash),
3045    )?;
3046    let mut files = Vec::new();
3047    for line in files_output.lines() {
3048        let Some((status, path)) = line.split_once('\t') else {
3049            continue;
3050        };
3051        files.push(CommitFile {
3052            status: status.to_string(),
3053            path: path.to_string(),
3054        });
3055    }
3056    Ok(CommitInfo {
3057        hash: parts[0].trim().to_string(),
3058        subject: parts[1].trim().to_string(),
3059        author: parts[2].trim().to_string(),
3060        email: parts[3].trim().to_string(),
3061        timestamp: parts[4].trim().parse().unwrap_or_default(),
3062        body: parts[5].trim().to_string(),
3063        files,
3064    })
3065}
3066
3067fn raw_commit_object(repo: &Path, hash: &str) -> Result<Vec<u8>> {
3068    validate_refish(hash)?;
3069    let output = run_git_output(
3070        Command::new("git")
3071            .arg("--git-dir")
3072            .arg(repo)
3073            .arg("cat-file")
3074            .arg("-p")
3075            .arg(hash),
3076        GIT_COMMAND_TIMEOUT,
3077    )?;
3078    if !output.status.success() {
3079        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3080    }
3081    Ok(output.stdout)
3082}
3083
3084fn run_git(cmd: &mut Command) -> Result<String> {
3085    let output = run_git_output(cmd, GIT_COMMAND_TIMEOUT)?;
3086    if !output.status.success() {
3087        return Err(Error::msg(String::from_utf8_lossy(&output.stderr)));
3088    }
3089    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
3090}
3091
3092fn run_git_output(cmd: &mut Command, timeout: Duration) -> Result<Output> {
3093    let mut child = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
3094    let deadline = Instant::now() + timeout;
3095
3096    loop {
3097        if child.try_wait()?.is_some() {
3098            return child.wait_with_output().map_err(Error::from);
3099        }
3100
3101        if Instant::now() >= deadline {
3102            let _ = child.kill();
3103            let _ = child.wait();
3104            log::warn!("Git command execution timed out");
3105            return Err(Error::msg("Git command execution timed out"));
3106        }
3107
3108        let sleep_for = deadline
3109            .saturating_duration_since(Instant::now())
3110            .min(Duration::from_millis(10));
3111        if !sleep_for.is_zero() {
3112            thread::sleep(sleep_for);
3113        }
3114    }
3115}
3116
3117#[cfg(test)]
3118mod tests {
3119    use super::*;
3120    use crate::acl::Access;
3121    use crate::config::ServerConfig;
3122    use crate::logging;
3123    use rns_core::msgpack::{self, Value};
3124    use rns_crypto::OsRng;
3125    use std::fs;
3126    use std::process::Command;
3127
3128    #[test]
3129    fn nomadnet_destination_uses_upstream_name() {
3130        let identity = Identity::new(&mut OsRng);
3131        let destination = destination_for_identity(&identity);
3132        let expected =
3133            Destination::single_in("nomadnetwork", &["node"], IdentityHash(*identity.hash()));
3134        assert_eq!(destination.hash, expected.hash);
3135    }
3136
3137    #[test]
3138    fn chart_renders_positive_values_on_bottom_row() {
3139        let labels = ["start".to_string(), "end".to_string()];
3140        let chart = render_chart(&[1, 100], &labels, "57c", None, 1.3, 10);
3141        let bottom_row = chart.lines().nth(10).unwrap();
3142
3143        assert!(bottom_row.starts_with("│`FT"));
3144        assert!(bottom_row.contains('▄'));
3145    }
3146
3147    #[test]
3148    fn git_command_timeout_kills_slow_process() {
3149        let start = Instant::now();
3150
3151        let err =
3152            run_git_output(Command::new("sleep").arg("5"), Duration::from_millis(30)).unwrap_err();
3153
3154        assert!(start.elapsed() < Duration::from_secs(1));
3155        assert!(err.to_string().contains("timed out"));
3156    }
3157
3158    #[test]
3159    fn commit_message_formatter_escapes_leading_dash_lines() {
3160        let formatted = format_commit_message("subject\n- rendered literally\npath\\with`tick`");
3161
3162        assert_eq!(
3163            formatted,
3164            "subject\n\\- rendered literally\npath\\\\with\\`tick\\`"
3165        );
3166    }
3167
3168    #[test]
3169    fn front_page_lists_only_readable_groups() {
3170        let tmp = tempfile::tempdir().unwrap();
3171        let mut config = cfg(tmp.path());
3172        config.allow_read = vec!["none".into()];
3173        create_repo(
3174            config.repositories_dir.join("public/alpha"),
3175            "README.md",
3176            "alpha readme\n",
3177        );
3178        create_repo(
3179            config.repositories_dir.join("private/secret"),
3180            "README.md",
3181            "secret\n",
3182        );
3183        fs::write(
3184            config.repositories_dir.join("public/group.allowed"),
3185            "read = all\n",
3186        )
3187        .unwrap();
3188        fs::write(
3189            config.repositories_dir.join("private/group.allowed"),
3190            "read = none\n",
3191        )
3192        .unwrap();
3193        let access = access(&config);
3194
3195        let page = render_page(PATH_INDEX, &config, &access, &page_request(&[]), None).unwrap();
3196        assert!(page.contains("public"));
3197        assert!(page.contains("alpha"));
3198        assert!(!page.contains("private"));
3199        assert!(!page.contains("secret"));
3200    }
3201
3202    #[test]
3203    fn group_and_repo_pages_use_repository_metadata() {
3204        let tmp = tempfile::tempdir().unwrap();
3205        let config = cfg(tmp.path());
3206        let repo = create_repo(
3207            config.repositories_dir.join("public/alpha"),
3208            "README.md",
3209            "# Alpha\n",
3210        );
3211        fs::write(repo.join("description"), "Alpha repository\n").unwrap();
3212        let access = access(&config);
3213
3214        let group = render_page(
3215            PATH_GROUP,
3216            &config,
3217            &access,
3218            &page_request(&[("var_g", "public")]),
3219            None,
3220        )
3221        .unwrap();
3222        assert!(group.contains("Alpha repository"));
3223        assert!(group.contains("alpha"));
3224
3225        let repo = render_page(
3226            PATH_REPO,
3227            &config,
3228            &access,
3229            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3230            None,
3231        )
3232        .unwrap();
3233        assert!(repo.contains("rns://"));
3234        assert!(repo.contains("Alpha repository"));
3235        assert!(repo.contains(">Alpha"));
3236    }
3237
3238    #[test]
3239    fn repo_page_renders_fork_and_mirror_sources() {
3240        let tmp = tempfile::tempdir().unwrap();
3241        let config = cfg(tmp.path());
3242        let fork = create_repo(
3243            config.repositories_dir.join("public/forked"),
3244            "README.md",
3245            "# Forked\n",
3246        );
3247        let mirror = create_repo(
3248            config.repositories_dir.join("public/mirrored"),
3249            "README.md",
3250            "# Mirrored\n",
3251        );
3252        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3253            "config",
3254            "repository.rngit.type",
3255            "fork",
3256        ]));
3257        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3258            "config",
3259            "repository.rngit.upstream.source",
3260            "rns://00112233445566778899aabbccddeeff/source/repo",
3261        ]));
3262        run_git(Command::new("git").arg("--git-dir").arg(&fork).args([
3263            "config",
3264            "repository.rngit.upstream.sync",
3265            "1",
3266        ]));
3267        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3268            "config",
3269            "repository.rngit.type",
3270            "mirror",
3271        ]));
3272        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3273            "config",
3274            "repository.rngit.upstream.source",
3275            "https://example.invalid/upstream.git",
3276        ]));
3277        run_git(Command::new("git").arg("--git-dir").arg(&mirror).args([
3278            "config",
3279            "repository.rngit.upstream.sync",
3280            "1",
3281        ]));
3282        let access = access(&config);
3283
3284        let fork_page = render_page(
3285            PATH_REPO,
3286            &config,
3287            &access,
3288            &page_request(&[("var_g", "public"), ("var_r", "forked")]),
3289            None,
3290        )
3291        .unwrap();
3292        assert!(fork_page.contains("Forked from"));
3293        assert!(fork_page.contains("rns://00112233445566778899aabbccddeeff/source/repo"));
3294        assert!(fork_page.contains("synced"));
3295        assert!(fork_page.contains("ago"));
3296
3297        let mirror_page = render_page(
3298            PATH_REPO,
3299            &config,
3300            &access,
3301            &page_request(&[("var_g", "public"), ("var_r", "mirrored")]),
3302            None,
3303        )
3304        .unwrap();
3305        assert!(mirror_page.contains("Mirrored from"));
3306        assert!(mirror_page.contains("https://example.invalid/upstream.git"));
3307        assert!(mirror_page.contains("synced"));
3308        assert!(mirror_page.contains("ago"));
3309    }
3310
3311    #[test]
3312    fn tree_blob_commits_commit_and_refs_pages_are_git_backed() {
3313        let tmp = tempfile::tempdir().unwrap();
3314        let config = cfg(tmp.path());
3315        let repo = create_repo(
3316            config.repositories_dir.join("public/alpha"),
3317            "src/lib.rs",
3318            "pub fn answer() -> u8 { 42 }\n",
3319        );
3320        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3321            "tag",
3322            "v1",
3323            "refs/heads/main",
3324        ]));
3325        let access = access(&config);
3326
3327        let tree = render_page(
3328            PATH_TREE,
3329            &config,
3330            &access,
3331            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3332            None,
3333        )
3334        .unwrap();
3335        assert!(tree.contains("src/"));
3336
3337        let blob = render_page(
3338            PATH_BLOB,
3339            &config,
3340            &access,
3341            &page_request(&[
3342                ("var_g", "public"),
3343                ("var_r", "alpha"),
3344                ("var_path", "src/lib.rs"),
3345            ]),
3346            None,
3347        )
3348        .unwrap();
3349        assert!(blob.contains("answer"));
3350        assert!(blob.contains("pub fn answer()"));
3351
3352        let commits = render_page(
3353            PATH_COMMITS,
3354            &config,
3355            &access,
3356            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3357            None,
3358        )
3359        .unwrap();
3360        assert!(commits.contains("initial"));
3361
3362        let commit_hash = run_git(
3363            Command::new("git")
3364                .arg("--git-dir")
3365                .arg(&repo)
3366                .args(["rev-parse", "refs/heads/main"]),
3367        );
3368        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3369            "update-ref",
3370            "refs/heads/feature",
3371            commit_hash.trim(),
3372        ]));
3373        let branch_commits = render_page(
3374            PATH_COMMITS,
3375            &config,
3376            &access,
3377            &page_request(&[
3378                ("var_g", "public"),
3379                ("var_r", "alpha"),
3380                ("var_ref", "feature"),
3381            ]),
3382            None,
3383        )
3384        .unwrap();
3385        assert!(branch_commits.contains("|ref=feature|h="));
3386        let commit = render_page(
3387            PATH_COMMIT,
3388            &config,
3389            &access,
3390            &page_request(&[
3391                ("var_g", "public"),
3392                ("var_r", "alpha"),
3393                ("var_ref", "feature"),
3394                ("var_h", commit_hash.trim()),
3395            ]),
3396            None,
3397        )
3398        .unwrap();
3399        assert!(commit.contains("initial"));
3400        assert!(commit.contains("src/lib.rs"));
3401        assert!(commit.contains("/page/commits.mu`g=public|r=alpha|ref=feature"));
3402        assert!(!commit.contains("Signature:"));
3403
3404        let refs = render_page(
3405            PATH_REFS,
3406            &config,
3407            &access,
3408            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3409            None,
3410        )
3411        .unwrap();
3412        assert!(refs.contains("main"));
3413        assert!(refs.contains("v1"));
3414    }
3415
3416    #[test]
3417    fn commit_page_shows_valid_reticulum_signature_status() {
3418        let tmp = tempfile::tempdir().unwrap();
3419        let config = cfg(tmp.path());
3420        let repo = create_repo(
3421            config.repositories_dir.join("public/alpha"),
3422            "src/lib.rs",
3423            "pub fn answer() -> u8 { 42 }\n",
3424        );
3425        let access = access(&config);
3426        let tree = run_git(
3427            Command::new("git")
3428                .arg("--git-dir")
3429                .arg(&repo)
3430                .args(["rev-parse", "refs/heads/main^{tree}"]),
3431        );
3432        let identity = Identity::new(&mut OsRng);
3433        let author = crate::util::hex(identity.hash());
3434        let signed_content = format!(
3435            "tree {}\nauthor RNS Page Test <{}> 0 +0000\ncommitter RNS Page Test <{}> 0 +0000\n\nsigned commit\n",
3436            tree.trim(),
3437            author,
3438            author
3439        );
3440        let armored =
3441            crate::commitsigs::sign_message(&identity, signed_content.as_bytes()).unwrap();
3442        let (headers, rest) = signed_content.split_once("\n\n").unwrap();
3443        let mut raw_commit = format!("{headers}\n");
3444        let mut sig_lines = armored.lines();
3445        raw_commit.push_str("gpgsig ");
3446        raw_commit.push_str(sig_lines.next().unwrap());
3447        raw_commit.push('\n');
3448        for line in sig_lines {
3449            raw_commit.push(' ');
3450            raw_commit.push_str(line);
3451            raw_commit.push('\n');
3452        }
3453        raw_commit.push('\n');
3454        raw_commit.push_str(rest);
3455        let commit_file = tmp.path().join("signed-commit");
3456        fs::write(&commit_file, raw_commit).unwrap();
3457        let commit_hash = run_git(
3458            Command::new("git")
3459                .arg("--git-dir")
3460                .arg(&repo)
3461                .args(["hash-object", "-t", "commit", "-w"])
3462                .arg(&commit_file),
3463        );
3464        run_git(Command::new("git").arg("--git-dir").arg(&repo).args([
3465            "update-ref",
3466            "refs/heads/main",
3467            commit_hash.trim(),
3468        ]));
3469
3470        let page = render_page(
3471            PATH_COMMIT,
3472            &config,
3473            &access,
3474            &page_request(&[
3475                ("var_g", "public"),
3476                ("var_r", "alpha"),
3477                ("var_h", commit_hash.trim()),
3478            ]),
3479            None,
3480        )
3481        .unwrap();
3482
3483        assert!(page.contains("signed commit"));
3484        assert!(page.contains("Signature: Valid, signed by author"));
3485    }
3486
3487    #[test]
3488    fn blob_page_renders_tree_when_path_is_directory() {
3489        let tmp = tempfile::tempdir().unwrap();
3490        let config = cfg(tmp.path());
3491        create_repo(
3492            config.repositories_dir.join("public/alpha"),
3493            "src/lib.rs",
3494            "pub fn answer() -> u8 { 42 }\n",
3495        );
3496        let access = access(&config);
3497
3498        let page = render_page(
3499            PATH_BLOB,
3500            &config,
3501            &access,
3502            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_path", "src")]),
3503            None,
3504        )
3505        .unwrap();
3506
3507        assert!(page.contains("files / src"));
3508        assert!(page.contains("lib.rs"));
3509        assert!(page.contains(PATH_BLOB));
3510        assert!(!page.contains("Displaying Raw"));
3511    }
3512
3513    #[test]
3514    fn page_requests_reject_invalid_repository_names() {
3515        let tmp = tempfile::tempdir().unwrap();
3516        let config = cfg(tmp.path());
3517        let access = access(&config);
3518        let err = render_page(
3519            PATH_REPO,
3520            &config,
3521            &access,
3522            &page_request(&[("var_g", ".."), ("var_r", "repo")]),
3523            None,
3524        )
3525        .unwrap_err();
3526        assert!(err.to_string().contains("invalid repository name"));
3527    }
3528
3529    #[test]
3530    fn page_ref_resolution_rejects_invalid_refs_before_git_lookup() {
3531        let tmp = tempfile::tempdir().unwrap();
3532        let config = cfg(tmp.path());
3533        create_repo(
3534            config.repositories_dir.join("public/alpha"),
3535            "README.md",
3536            "# Alpha\n",
3537        );
3538        let access = access(&config);
3539
3540        for (path, vars) in [
3541            (
3542                PATH_TREE,
3543                vec![
3544                    ("var_g", "public"),
3545                    ("var_r", "alpha"),
3546                    ("var_ref", "--upload-pack=/tmp/x"),
3547                ],
3548            ),
3549            (
3550                PATH_BLOB,
3551                vec![
3552                    ("var_g", "public"),
3553                    ("var_r", "alpha"),
3554                    ("var_path", "README.md"),
3555                    ("var_ref", "refs/heads/main:README.md"),
3556                ],
3557            ),
3558            (
3559                PATH_COMMITS,
3560                vec![
3561                    ("var_g", "public"),
3562                    ("var_r", "alpha"),
3563                    ("var_ref", "refs/heads/main..refs/heads/other"),
3564                ],
3565            ),
3566        ] {
3567            let err = render_page(path, &config, &access, &page_request(&vars), None).unwrap_err();
3568            assert_eq!(err.to_string(), "invalid git ref");
3569        }
3570    }
3571
3572    #[test]
3573    fn pages_use_base_template_breadcrumbs_and_micron_links() {
3574        let tmp = tempfile::tempdir().unwrap();
3575        let config = cfg(tmp.path());
3576        create_repo(
3577            config.repositories_dir.join("public/alpha"),
3578            "README.md",
3579            "# Alpha\n",
3580        );
3581        let access = access(&config);
3582
3583        let repo = render_page(
3584            PATH_REPO,
3585            &config,
3586            &access,
3587            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3588            None,
3589        )
3590        .unwrap();
3591
3592        assert!(repo.starts_with("#!c=0\n>Test Git Node\n\n>>\n"));
3593        assert!(repo.contains("`!`[Node`:/page/index.mu]`!"));
3594        assert!(repo.contains("`!`[public`:/page/group.mu`g=public]`!"));
3595        assert!(repo.contains("`[Files`:/page/tree.mu`g=public|r=alpha|ref=HEAD]"));
3596        assert!(repo.contains("`[Commits"));
3597        assert!(repo.contains("`[Branches (1)`:/page/refs.mu`g=public|r=alpha|type=heads]"));
3598        assert!(repo.contains("`[Tags (0)`:/page/refs.mu`g=public|r=alpha|type=tags]"));
3599        assert!(repo.contains("<\n-\n`a`F666`[Served by rngit`:/page/index.mu]"));
3600    }
3601
3602    #[test]
3603    fn custom_templates_wrap_page_content_and_replace_variables() {
3604        let tmp = tempfile::tempdir().unwrap();
3605        let config = cfg(tmp.path());
3606        fs::create_dir_all(&config.templates_dir).unwrap();
3607        fs::write(
3608            config.templates_dir.join("base.mu"),
3609            "BASE {NODE_NAME} {VERSION} {GEN_TIME}\n{PAGE_CONTENT}\n{NAVIGATION}   \n",
3610        )
3611        .unwrap();
3612        fs::write(
3613            config.templates_dir.join("repo.mu"),
3614            "REPO-BEGIN\n{PAGE_CONTENT}\nREPO-END   \n",
3615        )
3616        .unwrap();
3617        create_repo(
3618            config.repositories_dir.join("public/alpha"),
3619            "README.md",
3620            "# Alpha\n",
3621        );
3622        let access = access(&config);
3623
3624        let repo = render_page(
3625            PATH_REPO,
3626            &config,
3627            &access,
3628            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3629            None,
3630        )
3631        .unwrap();
3632
3633        assert!(repo.starts_with("BASE Test Git Node "));
3634        assert!(repo.contains("\nREPO-BEGIN\n"));
3635        assert!(repo.contains(">Alpha"));
3636        assert!(repo.contains("\nREPO-END\n"));
3637        assert!(repo.contains(env!("CARGO_PKG_VERSION")));
3638        assert!(repo.contains("0ms"));
3639        assert!(!repo.ends_with("   \n"));
3640    }
3641
3642    #[test]
3643    fn template_substitution_does_not_rewrite_page_content_placeholders() {
3644        let tmp = tempfile::tempdir().unwrap();
3645        let config = cfg(tmp.path());
3646
3647        let out = apply_template(
3648            "Template {NODE_NAME}\n{PAGE_CONTENT}",
3649            "Literal {NODE_NAME}",
3650            &config,
3651        );
3652
3653        assert!(out.starts_with("Template Test Git Node\n"));
3654        assert!(out.ends_with("Literal {NODE_NAME}"));
3655    }
3656
3657    #[test]
3658    fn unicode_icon_config_changes_repository_navigation_labels() {
3659        let tmp = tempfile::tempdir().unwrap();
3660        let mut config = cfg(tmp.path());
3661        config.unicode_icons = true;
3662        create_repo(
3663            config.repositories_dir.join("public/alpha"),
3664            "README.md",
3665            "# Alpha\n",
3666        );
3667        let access = access(&config);
3668
3669        let repo = render_page(
3670            PATH_REPO,
3671            &config,
3672            &access,
3673            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3674            None,
3675        )
3676        .unwrap();
3677
3678        assert!(repo.contains("🗀 Files"));
3679        assert!(repo.contains("🖹 Commits"));
3680        assert!(repo.contains("⑃ Branches"));
3681        assert!(repo.contains("⌆ Tags"));
3682    }
3683
3684    #[test]
3685    fn repo_page_renders_and_persists_thanks_count() {
3686        let tmp = tempfile::tempdir().unwrap();
3687        let config = cfg(tmp.path());
3688        let repo_path = create_repo(
3689            config.repositories_dir.join("public/alpha"),
3690            "README.md",
3691            "# Alpha\n",
3692        );
3693        let access = access(&config);
3694
3695        let first = render_page(
3696            PATH_REPO,
3697            &config,
3698            &access,
3699            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3700            None,
3701        )
3702        .unwrap();
3703        assert!(first.contains("Thanks (0)"));
3704        assert!(!repo_path.with_extension("thanks").exists());
3705
3706        let thanked = render_page(
3707            PATH_REPO,
3708            &config,
3709            &access,
3710            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
3711            None,
3712        )
3713        .unwrap();
3714        assert!(thanked.contains("Thanks (1)"));
3715        assert_eq!(
3716            fs::read_to_string(repo_path.with_extension("thanks")).unwrap(),
3717            "1\n"
3718        );
3719
3720        let incremented = render_page(
3721            PATH_REPO,
3722            &config,
3723            &access,
3724            &page_request(&[("var_g", "public"), ("var_r", "alpha"), ("var_thanks", "y")]),
3725            None,
3726        )
3727        .unwrap();
3728        assert!(incremented.contains("Thanks (2)"));
3729
3730        let viewed = render_page(
3731            PATH_REPO,
3732            &config,
3733            &access,
3734            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3735            None,
3736        )
3737        .unwrap();
3738        assert!(viewed.contains("Thanks (2)"));
3739    }
3740
3741    #[test]
3742    fn repo_page_uses_unicode_heart_for_thanks_when_enabled() {
3743        let tmp = tempfile::tempdir().unwrap();
3744        let mut config = cfg(tmp.path());
3745        config.unicode_icons = true;
3746        create_repo(
3747            config.repositories_dir.join("public/alpha"),
3748            "README.md",
3749            "# Alpha\n",
3750        );
3751        let access = access(&config);
3752
3753        let page = render_page(
3754            PATH_REPO,
3755            &config,
3756            &access,
3757            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3758            None,
3759        )
3760        .unwrap();
3761
3762        assert!(page.contains("♥ Thanks (0)"));
3763        assert!(page.contains("thanks=y"));
3764    }
3765
3766    #[test]
3767    fn empty_page_request_data_is_deterministic() {
3768        let tmp = tempfile::tempdir().unwrap();
3769        let config = cfg(tmp.path());
3770        let access = access(&config);
3771
3772        let index = render_page(PATH_INDEX, &config, &access, &[], None).unwrap();
3773        assert!(index.contains(">Groups"));
3774
3775        let err = render_page(PATH_REPO, &config, &access, &[], None).unwrap_err();
3776        assert!(err.to_string().contains("missing page variable g"));
3777    }
3778
3779    #[test]
3780    fn tree_blob_and_commit_pages_escape_content_and_format_metadata() {
3781        let tmp = tempfile::tempdir().unwrap();
3782        let config = cfg(tmp.path());
3783        let repo = create_repo(
3784            config.repositories_dir.join("public/alpha"),
3785            "src/lib.rs",
3786            "pub fn `answer`() -> u8 { 42 }\n",
3787        );
3788        let access = access(&config);
3789
3790        let tree = render_page(
3791            PATH_TREE,
3792            &config,
3793            &access,
3794            &page_request(&[("var_g", "public"), ("var_r", "alpha")]),
3795            None,
3796        )
3797        .unwrap();
3798        assert!(tree.contains("`[src/`:/page/tree.mu`g=public|r=alpha|ref=HEAD|path=src]"));
3799
3800        let blob = render_page(
3801            PATH_BLOB,
3802            &config,
3803            &access,
3804            &page_request(&[
3805                ("var_g", "public"),
3806                ("var_r", "alpha"),
3807                ("var_path", "src/lib.rs"),
3808            ]),
3809            None,
3810        )
3811        .unwrap();
3812        assert!(blob.contains("Text, "));
3813        assert!(!blob.contains("View raw"));
3814        assert!(blob.contains("answer"));
3815        assert!(blob.contains("\\`"));
3816
3817        let silly_blob = render_page(
3818            PATH_BLOB,
3819            &config,
3820            &access,
3821            &page_request(&[
3822                ("var_g", "public"),
3823                ("var_r", "alpha"),
3824                ("var_path", "./src/./lib.rs"),
3825            ]),
3826            None,
3827        )
3828        .unwrap();
3829        assert!(silly_blob.contains(">src/lib.rs `F666"));
3830        assert!(silly_blob.contains("answer"));
3831
3832        let commit_hash = run_git(
3833            Command::new("git")
3834                .arg("--git-dir")
3835                .arg(&repo)
3836                .args(["rev-parse", "refs/heads/main"]),
3837        );
3838        let commit = render_page(
3839            PATH_COMMIT,
3840            &config,
3841            &access,
3842            &page_request(&[
3843                ("var_g", "public"),
3844                ("var_r", "alpha"),
3845                ("var_h", commit_hash.trim()),
3846            ]),
3847            None,
3848        )
3849        .unwrap();
3850        assert!(commit.contains("Author: RNS Page Test"));
3851        assert!(commit.contains("Files changed"));
3852        assert!(commit.contains("A src/lib.rs"));
3853    }
3854
3855    #[test]
3856    fn markdown_converter_handles_links_code_lists_rules_and_tables() {
3857        let input = "# Title\n\
3858\n\
3859See [the docs](https://example.invalid/a_b) and `literal *code*`.\n\
3860\n\
3861- **bold** and *italic*\n\
3862---\n\
3863| Name | Link |\n\
3864| --- | --- |\n\
3865| Row | [go](rns://abc) |\n";
3866
3867        let out = markdown_to_micron(input);
3868        assert!(out.contains(">Title"));
3869        assert!(out.contains("`_`!`[the docs`https://example.invalid/a_b]`!`_"));
3870        assert!(out.contains("`BT383838`Fdddliteral *code*`f`b"));
3871        assert!(out.contains(" • `!bold`! and `*italic`*"));
3872        assert!(out.contains("\n-\n"));
3873        assert!(out.contains("│ Name"));
3874        assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
3875    }
3876
3877    #[test]
3878    fn markdown_converter_handles_blockquotes_and_heading_inline_markup() {
3879        let out = markdown_to_micron(
3880            "# **Important**\n\
3881> **Quoted** text\n\
3882> with [link](rns://abc)\n\
3883\n\
3884after\n",
3885        );
3886        assert!(out.contains(">`!Important`!"));
3887        assert!(out.contains(" │ `!Quoted`! text with `_`!`[link`rns://abc]`!`_"));
3888        assert!(out.contains("\nafter\n"));
3889    }
3890
3891    #[test]
3892    fn markdown_link_conversion_is_isolated_from_later_substitutions() {
3893        let out = markdown_to_micron("[**bold** `code`](https://example.invalid?q=*x*)");
3894        assert!(out.contains("`_`!`[**bold** code`https://example.invalid?q=*x*]`!`_"));
3895        assert!(!out.contains("`!`!"));
3896        assert!(!out.contains("`*x`*"));
3897    }
3898
3899    #[test]
3900    fn markdown_link_style_can_disable_wrappers_and_apply_color() {
3901        let plain = format_markdown_link(
3902            "label",
3903            "rns://abc",
3904            None,
3905            MarkdownLinkStyle {
3906                bold: false,
3907                underline: false,
3908                color: None,
3909            },
3910        );
3911        assert_eq!(plain, "`[label`rns://abc]");
3912
3913        let short_color = format_markdown_link(
3914            "label",
3915            "rns://abc",
3916            None,
3917            MarkdownLinkStyle {
3918                bold: true,
3919                underline: false,
3920                color: Some("abc"),
3921            },
3922        );
3923        assert_eq!(short_color, "`Fabc`!`[label`rns://abc]`!`f");
3924
3925        let true_color = format_markdown_link(
3926            "label",
3927            "rns://abc",
3928            None,
3929            MarkdownLinkStyle {
3930                bold: false,
3931                underline: true,
3932                color: Some("aabbcc"),
3933            },
3934        );
3935        assert_eq!(true_color, "`FTaabbcc`_`[label`rns://abc]`_`f");
3936    }
3937
3938    #[test]
3939    fn markdown_inline_code_that_looks_like_link_does_not_hide_real_links() {
3940        let out = markdown_to_micron(
3941            "`[literal](https://example.invalid/no)` and [real](https://example.invalid/yes)",
3942        );
3943
3944        assert!(out.contains("`BT383838`Fddd[literal](https://example.invalid/no)`f`b"));
3945        assert!(out.contains("`_`!`[real`https://example.invalid/yes]`!`_"));
3946        assert!(!out.contains("`_`!`[literal`https://example.invalid/no]`!`_"));
3947    }
3948
3949    #[test]
3950    fn markdown_tables_handle_empty_cells_escaped_pipes_alignment_and_visible_width() {
3951        let input = "| Name | Status | Notes |\n\
3952| :--- | ---: | :---: |\n\
3953| `code` | **ok** | a\\|b |\n\
3954| empty | | [go](rns://abc) |\n";
3955
3956        let out = markdown_to_micron(input);
3957        assert!(out.contains("┌"));
3958        assert!(out.contains("├"));
3959        assert!(out.contains("└"));
3960        assert!(out.contains("│ Name"));
3961        assert!(out.contains("`BT383838`Fdddcode`f`b"));
3962        assert!(out.contains("`!ok`!"));
3963        assert!(out.contains("a|b"));
3964        assert!(out.contains("empty"));
3965        assert!(out.contains("`_`!`[go`rns://abc]`!`_"));
3966        assert!(!out.contains("a\\|b"));
3967    }
3968
3969    #[test]
3970    fn markdown_table_width_ignores_generated_micron_tags() {
3971        let input = "| A | B |\n\
3972| --- | --- |\n\
3973| [x](rns://abc) | `y` |\n";
3974
3975        let out = markdown_to_micron(input);
3976        assert!(out.contains("│ `_`!`[x`rns://abc]`!`_ "));
3977        assert!(out.contains("│ `BT383838`Fdddy`f`b "));
3978        assert!(out.contains("┌─────┬─────┐"));
3979    }
3980
3981    #[test]
3982    fn markdown_escaping_does_not_create_or_corrupt_micron_tags() {
3983        let out =
3984            markdown_to_micron("literal `!not bold`! and [bad`label](rns://abc`def) plus `raw`");
3985        assert!(out.contains("literal \\`!not bold\\`!"));
3986        assert!(out.contains("`_`!`[badlabel`rns://abcdef]`!`_"));
3987        assert!(out.contains("`BT383838`Fdddraw`f`b"));
3988        assert!(!out.contains("`!not bold`!"));
3989    }
3990
3991    #[test]
3992    fn markdown_escapes_backslashes_and_expands_tabs_before_inline_formatting() {
3993        let out = markdown_to_micron("plain\\path\t**bold\\path** and `code\\path`\n");
3994
3995        assert!(out.contains("plain\\\\path   `!bold\\\\path`!"));
3996        assert!(out.contains("`BT383838`Fdddcode\\\\path`f`b"));
3997    }
3998
3999    #[test]
4000    fn markdown_plain_lines_escape_micron_line_start_controls() {
4001        let out = markdown_to_micron("-not a list\n<not alignment\n- list item\n---\n");
4002        assert!(out.contains("\\-not a list\n"));
4003        assert!(out.contains("\\<not alignment\n"));
4004        assert!(out.contains(" • list item\n"));
4005        assert!(out.contains("-\n"));
4006    }
4007
4008    #[test]
4009    fn markdown_converter_keeps_code_blocks_and_unmatched_markers_literal() {
4010        let out = markdown_to_micron(
4011            "```rust\n\
4012**not bold** and `not inline`\n\
4013```\n\
4014Unmatched * marker\n\
40151. numbered\n",
4016        );
4017        assert!(out.contains("not bold"));
4018        assert!(out.contains("\\`not inline\\`"));
4019        assert!(out.contains("`=\n**not bold** and \\`not inline\\`\n`=\n"));
4020        assert!(!out.contains("`!not bold`!"));
4021        assert!(out.contains("Unmatched * marker"));
4022        assert!(out.contains("1. numbered"));
4023    }
4024
4025    #[test]
4026    fn rawmu_fenced_blocks_pass_through_without_escaping() {
4027        let markdown = markdown_to_micron(
4028            "```rawmu
4029`!raw micron`!
4030>Heading
4031```
4032",
4033        );
4034
4035        assert!(markdown.contains(
4036            "`!raw micron`!
4037>Heading
4038"
4039        ));
4040        assert!(!markdown.contains(r"\`!raw micron"));
4041        assert!(!markdown.contains(
4042            "`=
4043"
4044        ));
4045    }
4046
4047    #[test]
4048    fn unknown_extension_and_fence_language_fall_back_to_plain_literals() {
4049        let blob = crate::highlight::literal_block("value `tick`\n", Some("blob.unknown"), None);
4050        assert!(!blob.contains("`FT"));
4051        assert!(blob.contains("value \\`tick\\`"));
4052
4053        let markdown = markdown_to_micron("```not-a-language\nvalue `tick`\n```\n");
4054        assert!(!markdown.contains("`FT"));
4055        assert!(markdown.contains("value \\`tick\\`"));
4056    }
4057
4058    #[test]
4059    fn binary_and_oversized_blobs_use_plain_fallback_messages() {
4060        let tmp = tempfile::tempdir().unwrap();
4061        let config = cfg(tmp.path());
4062        create_repo_bytes(
4063            config.repositories_dir.join("public/binary"),
4064            "payload.bin",
4065            b"abc\0def",
4066        );
4067        let large = vec![b'x'; 256 * 1024 + 1];
4068        create_repo_bytes(
4069            config.repositories_dir.join("public/large"),
4070            "payload.rs",
4071            &large,
4072        );
4073        let access = access(&config);
4074
4075        let binary = render_page(
4076            PATH_BLOB,
4077            &config,
4078            &access,
4079            &page_request(&[
4080                ("var_g", "public"),
4081                ("var_r", "binary"),
4082                ("var_path", "payload.bin"),
4083            ]),
4084            None,
4085        )
4086        .unwrap();
4087        assert!(binary.contains("Binary"));
4088        assert!(binary.contains("Binary file is not displayed."));
4089        assert!(!binary.contains("`FT"));
4090
4091        let oversized = render_page(
4092            PATH_BLOB,
4093            &config,
4094            &access,
4095            &page_request(&[
4096                ("var_g", "public"),
4097                ("var_r", "large"),
4098                ("var_path", "payload.rs"),
4099            ]),
4100            None,
4101        )
4102        .unwrap();
4103        assert!(oversized.contains("File is too large to display"));
4104        assert!(!oversized.contains("`FT"));
4105    }
4106
4107    #[test]
4108    fn markdown_blob_defaults_to_rendered_view_and_raw_view_escapes_source() {
4109        let tmp = tempfile::tempdir().unwrap();
4110        let config = cfg(tmp.path());
4111        create_repo(
4112            config.repositories_dir.join("public/docs"),
4113            "docs/readme.md",
4114            "# Title\n\nSee [docs](https://example.invalid), [guide](guide.md#intro) and `tick`.\n",
4115        );
4116        let access = access(&config);
4117
4118        let rendered = render_page(
4119            PATH_BLOB,
4120            &config,
4121            &access,
4122            &page_request(&[
4123                ("var_g", "public"),
4124                ("var_r", "docs"),
4125                ("var_path", "docs/readme.md"),
4126            ]),
4127            None,
4128        )
4129        .unwrap();
4130        assert!(rendered.contains("Displaying Rendered"));
4131        assert!(rendered.contains("View raw"));
4132        assert!(rendered.contains(">Title"));
4133        assert!(rendered.contains("`_`!`[docs`https://example.invalid]`!`_"));
4134        assert!(rendered.contains(
4135            "`_`!`[guide`:/page/blob.mu`g=public|r=docs|ref=HEAD|path=docs/guide.md|anchor=intro]`!`_"
4136        ));
4137        assert!(rendered.contains("`BT383838`Fdddtick`f`b"));
4138        assert!(!rendered.contains("# Title"));
4139
4140        let raw = render_page(
4141            PATH_BLOB,
4142            &config,
4143            &access,
4144            &page_request(&[
4145                ("var_g", "public"),
4146                ("var_r", "docs"),
4147                ("var_path", "docs/readme.md"),
4148                ("var_raw", "y"),
4149            ]),
4150            None,
4151        )
4152        .unwrap();
4153        assert!(raw.contains("Displaying Raw"));
4154        assert!(raw.contains("View rendered"));
4155        assert!(raw.contains("Title"));
4156        assert!(raw.contains("\\`"));
4157        assert!(raw.contains("tick"));
4158        assert!(!raw.contains("`_`!`[docs`https://example.invalid]`!`_"));
4159    }
4160
4161    #[test]
4162    fn micron_blob_defaults_to_rendered_passthrough_and_raw_view_is_literal() {
4163        let tmp = tempfile::tempdir().unwrap();
4164        let config = cfg(tmp.path());
4165        create_repo(
4166            config.repositories_dir.join("public/micron"),
4167            "README.mu",
4168            ">Micron\n\n`!Already formatted`!\n",
4169        );
4170        let access = access(&config);
4171
4172        let rendered = render_page(
4173            PATH_BLOB,
4174            &config,
4175            &access,
4176            &page_request(&[
4177                ("var_g", "public"),
4178                ("var_r", "micron"),
4179                ("var_path", "README.mu"),
4180            ]),
4181            None,
4182        )
4183        .unwrap();
4184        assert!(rendered.contains("Displaying Rendered"));
4185        assert!(rendered.contains(">Micron"));
4186        assert!(rendered.contains("`!Already formatted`!"));
4187        assert!(!rendered.contains("\\`!Already formatted\\`!"));
4188
4189        let raw = render_page(
4190            PATH_BLOB,
4191            &config,
4192            &access,
4193            &page_request(&[
4194                ("var_g", "public"),
4195                ("var_r", "micron"),
4196                ("var_path", "README.mu"),
4197                ("var_raw", "y"),
4198            ]),
4199            None,
4200        )
4201        .unwrap();
4202        assert!(raw.contains("Displaying Raw"));
4203        assert!(raw.contains("\\`!Already formatted\\`!"));
4204    }
4205
4206    #[test]
4207    fn explicit_render_parameter_renders_markdown_blob() {
4208        let tmp = tempfile::tempdir().unwrap();
4209        let config = cfg(tmp.path());
4210        create_repo(
4211            config.repositories_dir.join("public/docs"),
4212            "docs/readme.md",
4213            "# Explicit\n",
4214        );
4215        let access = access(&config);
4216
4217        let rendered = render_page(
4218            PATH_BLOB,
4219            &config,
4220            &access,
4221            &page_request(&[
4222                ("var_g", "public"),
4223                ("var_r", "docs"),
4224                ("var_path", "docs/readme.md"),
4225                ("var_raw", "y"),
4226                ("var_render", "y"),
4227            ]),
4228            None,
4229        )
4230        .unwrap();
4231        assert!(rendered.contains("Displaying Raw"));
4232        assert!(rendered.contains("#"));
4233        assert!(rendered.contains("Explicit"));
4234
4235        let rendered = render_page(
4236            PATH_BLOB,
4237            &config,
4238            &access,
4239            &page_request(&[
4240                ("var_g", "public"),
4241                ("var_r", "docs"),
4242                ("var_path", "docs/readme.md"),
4243                ("var_render", "y"),
4244            ]),
4245            None,
4246        )
4247        .unwrap();
4248        assert!(rendered.contains("Displaying Rendered"));
4249        assert!(rendered.contains(">Explicit"));
4250        assert!(!rendered.contains("# Explicit"));
4251    }
4252
4253    #[test]
4254    fn unsupported_blob_extension_ignores_render_parameter_and_uses_raw_literal() {
4255        let tmp = tempfile::tempdir().unwrap();
4256        let config = cfg(tmp.path());
4257        create_repo(
4258            config.repositories_dir.join("public/text"),
4259            "notes.txt",
4260            "# Not a heading\npath\\name\t`literal`\n",
4261        );
4262        let access = access(&config);
4263
4264        let page = render_page(
4265            PATH_BLOB,
4266            &config,
4267            &access,
4268            &page_request(&[
4269                ("var_g", "public"),
4270                ("var_r", "text"),
4271                ("var_path", "notes.txt"),
4272                ("var_render", "y"),
4273            ]),
4274            None,
4275        )
4276        .unwrap();
4277        assert!(!page.contains("Displaying Rendered"));
4278        assert!(!page.contains("View raw"));
4279        assert!(page.contains("Displaying Raw"));
4280        assert!(page.contains("Download"));
4281        assert!(page.contains("Not a heading"));
4282        assert!(page.contains("path\\\\name   \\`literal\\`"));
4283        assert!(page.contains("\\`literal\\`"));
4284    }
4285
4286    #[test]
4287    fn renderable_binary_and_oversized_blobs_do_not_render_content() {
4288        let tmp = tempfile::tempdir().unwrap();
4289        let config = cfg(tmp.path());
4290        create_repo_bytes(
4291            config.repositories_dir.join("public/binary-md"),
4292            "payload.md",
4293            b"# Binary\0payload\n",
4294        );
4295        let large = vec![b'#'; 256 * 1024 + 1];
4296        create_repo_bytes(
4297            config.repositories_dir.join("public/large-md"),
4298            "payload.md",
4299            &large,
4300        );
4301        let access = access(&config);
4302
4303        let binary = render_page(
4304            PATH_BLOB,
4305            &config,
4306            &access,
4307            &page_request(&[
4308                ("var_g", "public"),
4309                ("var_r", "binary-md"),
4310                ("var_path", "payload.md"),
4311            ]),
4312            None,
4313        )
4314        .unwrap();
4315        assert!(binary.contains("Binary"));
4316        assert!(binary.contains("Binary file is not displayed."));
4317        assert!(!binary.contains(">Binary"));
4318
4319        let oversized = render_page(
4320            PATH_BLOB,
4321            &config,
4322            &access,
4323            &page_request(&[
4324                ("var_g", "public"),
4325                ("var_r", "large-md"),
4326                ("var_path", "payload.md"),
4327                ("var_render", "y"),
4328            ]),
4329            None,
4330        )
4331        .unwrap();
4332        assert!(oversized.contains("File is too large to display"));
4333        assert!(!oversized.contains("Displaying Rendered\n\n>"));
4334    }
4335
4336    #[test]
4337    fn code_blocks_render_plain_literal_blocks() {
4338        let blob = crate::highlight::literal_block("pub fn main() {}\n", Some("main.rs"), None);
4339        assert!(!blob.contains("`FT"));
4340        assert!(blob.contains("pub fn main() {}"));
4341
4342        let markdown = markdown_to_micron("```rust\npub fn main() {}\n```\n");
4343        assert!(!markdown.contains("`FT"));
4344        assert!(markdown.contains("pub fn main() {}"));
4345    }
4346
4347    #[test]
4348    fn repo_page_renders_markdown_readme_and_passes_micron_readme_through() {
4349        let tmp = tempfile::tempdir().unwrap();
4350        let config = cfg(tmp.path());
4351        create_repo(
4352            config.repositories_dir.join("public/markdown"),
4353            "README.md",
4354            "# Markdown\n\nSee [docs](https://example.invalid) and [local](docs/intro.md).\n",
4355        );
4356        create_repo(
4357            config.repositories_dir.join("public/micron"),
4358            "README.mu",
4359            ">Micron\n\n`!Already formatted`!\n",
4360        );
4361        create_repo(
4362            config.repositories_dir.join("public/microntight"),
4363            "README.mu",
4364            ">Micron without trailing newline",
4365        );
4366        create_repo(
4367            config.repositories_dir.join("public/micronblank"),
4368            "README.mu",
4369            ">Micron with trailing blanks\n\n\n",
4370        );
4371        let access = access(&config);
4372
4373        let markdown = render_page(
4374            PATH_REPO,
4375            &config,
4376            &access,
4377            &page_request(&[("var_g", "public"), ("var_r", "markdown")]),
4378            None,
4379        )
4380        .unwrap();
4381        assert!(markdown.contains(">Markdown"));
4382        assert!(markdown.contains("`_`!`[docs`https://example.invalid]`!`_"));
4383        assert!(markdown.contains(
4384            "`_`!`[local`:/page/blob.mu`g=public|r=markdown|ref=HEAD|path=docs/intro.md]`!`_"
4385        ));
4386
4387        let micron = render_page(
4388            PATH_REPO,
4389            &config,
4390            &access,
4391            &page_request(&[("var_g", "public"), ("var_r", "micron")]),
4392            None,
4393        )
4394        .unwrap();
4395        assert!(micron.contains(">Micron"));
4396        assert!(micron.contains("`!Already formatted`!"));
4397
4398        let micron = render_page(
4399            PATH_REPO,
4400            &config,
4401            &access,
4402            &page_request(&[("var_g", "public"), ("var_r", "microntight")]),
4403            None,
4404        )
4405        .unwrap();
4406        assert!(micron.contains(">Micron without trailing newline\n"));
4407        assert!(!micron.contains(">Micron without trailing newline<"));
4408
4409        let micron = render_page(
4410            PATH_REPO,
4411            &config,
4412            &access,
4413            &page_request(&[("var_g", "public"), ("var_r", "micronblank")]),
4414            None,
4415        )
4416        .unwrap();
4417        assert!(micron.contains(">Micron with trailing blanks\n"));
4418        assert!(!micron.contains(">Micron with trailing blanks\n\n\n"));
4419    }
4420
4421    #[test]
4422    fn work_pages_render_documents_comments_and_repo_links() {
4423        let tmp = tempfile::tempdir().unwrap();
4424        let config = cfg(tmp.path());
4425        let repo_path = create_repo(
4426            config.repositories_dir.join("public/worked"),
4427            "README.md",
4428            "# Worked\n",
4429        );
4430        let work_path = crate::work::work_sidecar_path(&repo_path);
4431        let created = crate::work::create_document(
4432            &work_path,
4433            crate::work::WorkInput {
4434                title: "Active task".into(),
4435                content: "# Active Body\n".into(),
4436                format: "markdown".into(),
4437                signature: None,
4438                identity: None,
4439                author: [0x11; 16],
4440            },
4441        )
4442        .unwrap();
4443        crate::work::add_comment(
4444            &work_path,
4445            crate::work::WorkScope::Active,
4446            created.id,
4447            crate::work::WorkCommentInput {
4448                content: "Progress update".into(),
4449                format: "markdown".into(),
4450                signature: None,
4451                author: [0x22; 16],
4452            },
4453        )
4454        .unwrap();
4455        let completed = crate::work::create_document(
4456            &work_path,
4457            crate::work::WorkInput {
4458                title: "Completed task".into(),
4459                content: "Done".into(),
4460                format: "micron".into(),
4461                signature: None,
4462                identity: None,
4463                author: [0x11; 16],
4464            },
4465        )
4466        .unwrap();
4467        crate::work::complete_document(&work_path, completed.id, &[0x11; 16]).unwrap();
4468        let access = access(&config);
4469
4470        let repo = render_page(
4471            PATH_REPO,
4472            &config,
4473            &access,
4474            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4475            None,
4476        )
4477        .unwrap();
4478        assert!(repo.contains("Work (2)"));
4479        assert!(repo.contains(PATH_WORK));
4480
4481        let active = render_page(
4482            PATH_WORK,
4483            &config,
4484            &access,
4485            &page_request(&[("var_g", "public"), ("var_r", "worked")]),
4486            None,
4487        )
4488        .unwrap();
4489        assert!(active.contains(">Active Work Documents (1)"));
4490        assert!(active.contains("#1 Active task"));
4491        assert!(active.contains("1 updates"));
4492        assert!(!active.contains("Completed task"));
4493
4494        let completed_page = render_page(
4495            PATH_WORK,
4496            &config,
4497            &access,
4498            &page_request(&[
4499                ("var_g", "public"),
4500                ("var_r", "worked"),
4501                ("var_scope", "completed"),
4502            ]),
4503            None,
4504        )
4505        .unwrap();
4506        assert!(completed_page.contains(">Completed Work Documents (1)"));
4507        assert!(completed_page.contains("Completed task"));
4508
4509        let doc = render_page(
4510            PATH_WORK_DOC,
4511            &config,
4512            &access,
4513            &page_request(&[("var_g", "public"), ("var_r", "worked"), ("var_id", "1")]),
4514            None,
4515        )
4516        .unwrap();
4517        assert!(doc.contains(">Active task"));
4518        assert!(doc.contains(">Active Body"));
4519        assert!(doc.contains(">Updates (1)"));
4520        assert!(doc.contains("Progress update"));
4521        assert!(doc.contains("`[Download`:/file/workdoc`g=public|r=worked|scope=active|id=1]"));
4522
4523        let completed_doc = render_page(
4524            PATH_WORK_DOC,
4525            &config,
4526            &access,
4527            &page_request(&[
4528                ("var_g", "public"),
4529                ("var_r", "worked"),
4530                ("var_id", &completed.id.to_string()),
4531            ]),
4532            None,
4533        )
4534        .unwrap();
4535        assert!(completed_doc.contains(">Completed task"));
4536        assert!(completed_doc.contains("Status: completed"));
4537        assert!(completed_doc.contains("`[work`:/page/work.mu`g=public|r=worked|scope=completed]"));
4538        assert!(completed_doc.contains(&format!(
4539            "`[Download`:/file/workdoc`g=public|r=worked|scope=completed|id={}]",
4540            completed.id
4541        )));
4542
4543        let active_download = download_work_document(
4544            &config,
4545            &access,
4546            &page_request(&[
4547                ("var_g", "public"),
4548                ("var_r", "worked"),
4549                ("var_scope", "active"),
4550                ("var_id", "1"),
4551            ]),
4552            None,
4553        )
4554        .unwrap();
4555        assert_resource_response(active_download, b"# Active Body");
4556
4557        let completed_download = download_work_document(
4558            &config,
4559            &access,
4560            &page_request(&[
4561                ("var_g", "public"),
4562                ("var_r", "worked"),
4563                ("var_id", &completed.id.to_string()),
4564            ]),
4565            None,
4566        )
4567        .unwrap();
4568        assert_resource_response(completed_download, b"Done");
4569    }
4570
4571    #[test]
4572    fn work_pages_respect_read_access() {
4573        let tmp = tempfile::tempdir().unwrap();
4574        let mut config = cfg(tmp.path());
4575        create_repo(
4576            config.repositories_dir.join("public/private-work"),
4577            "README.md",
4578            "# Private\n",
4579        );
4580        config.allow_read = vec!["none".into()];
4581        let access = access(&config);
4582
4583        assert!(render_page(
4584            PATH_WORK,
4585            &config,
4586            &access,
4587            &page_request(&[("var_g", "public"), ("var_r", "private-work")]),
4588            None,
4589        )
4590        .is_err());
4591    }
4592
4593    fn cfg(root: &std::path::Path) -> ServerConfig {
4594        ServerConfig {
4595            dir: root.to_path_buf(),
4596            reticulum_dir: None,
4597            repositories_dir: root.join("repositories"),
4598            identity_path: root.join("repositories_identity"),
4599            client_identity_path: root.join("client_identity"),
4600            node_name: "Test Git Node".into(),
4601            announce_interval_secs: 300,
4602            serve_nomadnet: true,
4603            templates_dir: root.join("templates"),
4604            unicode_icons: false,
4605            record_stats: false,
4606            stats_ignore_identities: Vec::new(),
4607            stats_push_ignore_identities: Vec::new(),
4608            blocked_identities: Vec::new(),
4609            identity_aliases: std::collections::BTreeMap::new(),
4610            allow_read: vec!["all".into()],
4611            allow_write: vec!["none".into()],
4612            allow_create: vec!["none".into()],
4613            allow_stats: vec!["none".into()],
4614            allow_release: vec!["none".into()],
4615            allow_interact: vec!["none".into()],
4616            allow_propose: vec!["none".into()],
4617            allow_admin: vec!["none".into()],
4618            log_level: logging::DEFAULT_LOG_LEVEL,
4619        }
4620    }
4621
4622    fn access(config: &ServerConfig) -> Access {
4623        Access::new(
4624            &config.allow_read,
4625            &config.allow_write,
4626            &config.allow_create,
4627            &config.allow_stats,
4628            &config.allow_release,
4629            &config.allow_interact,
4630            &config.allow_admin,
4631            config.repositories_dir.clone(),
4632        )
4633        .unwrap()
4634    }
4635
4636    fn page_request(fields: &[(&str, &str)]) -> Vec<u8> {
4637        msgpack::pack(&Value::Map(
4638            fields
4639                .iter()
4640                .map(|(k, v)| (Value::Str((*k).into()), Value::Str((*v).into())))
4641                .collect(),
4642        ))
4643    }
4644
4645    fn assert_resource_response(response: RequestResponse, expected: &[u8]) {
4646        match response {
4647            RequestResponse::Resource { data, metadata, .. } => {
4648                assert_eq!(data, expected);
4649                assert_eq!(
4650                    metadata.as_deref(),
4651                    Some(&protocol::metadata_status(protocol::RES_OK)[..])
4652                );
4653            }
4654            RequestResponse::Bytes(bytes) => panic!("expected resource response, got {bytes:?}"),
4655        }
4656    }
4657
4658    fn create_repo(path: std::path::PathBuf, file: &str, content: &str) -> std::path::PathBuf {
4659        create_repo_bytes(path, file, content.as_bytes())
4660    }
4661
4662    fn create_repo_bytes(
4663        path: std::path::PathBuf,
4664        file: &str,
4665        content: &[u8],
4666    ) -> std::path::PathBuf {
4667        let work = path.with_extension("work");
4668        fs::create_dir_all(&work).unwrap();
4669        run_git(Command::new("git").arg("init").arg(&work));
4670        let file_path = work.join(file);
4671        fs::create_dir_all(file_path.parent().unwrap()).unwrap();
4672        fs::write(&file_path, content).unwrap();
4673        run_git(Command::new("git").arg("-C").arg(&work).arg("add").arg("."));
4674        run_git(
4675            Command::new("git")
4676                .arg("-C")
4677                .arg(&work)
4678                .arg("-c")
4679                .arg("user.name=RNS Page Test")
4680                .arg("-c")
4681                .arg("user.email=rns-page-test@example.invalid")
4682                .arg("commit")
4683                .arg("-m")
4684                .arg("initial"),
4685        );
4686        run_git(
4687            Command::new("git")
4688                .arg("-C")
4689                .arg(&work)
4690                .args(["branch", "-M", "main"]),
4691        );
4692        run_git(
4693            Command::new("git")
4694                .arg("clone")
4695                .arg("--bare")
4696                .arg(&work)
4697                .arg(&path),
4698        );
4699        path
4700    }
4701
4702    fn run_git(cmd: &mut Command) -> String {
4703        let output = cmd.output().unwrap();
4704        assert!(
4705            output.status.success(),
4706            "git command failed: {}",
4707            String::from_utf8_lossy(&output.stderr)
4708        );
4709        String::from_utf8_lossy(&output.stdout).into_owned()
4710    }
4711}