| 1 | //! `get_contents`: what a repository holds at a path, at a branch, tag or |
| 2 | //! commit: a file's text, or a directory's entries with its README. What |
| 3 | //! an agent (or Ask g1t) reads to learn how a project builds and tests, |
| 4 | //! what a config says, or what a file does, without cloning it. |
| 5 | //! |
| 6 | //! The repos service reads it, as the caller may see it: `blob` for a |
| 7 | //! file, `tree` for a directory. A path that is not a file is tried as a |
| 8 | //! directory. The shaping is [`file`] and [`directory`], plain functions. |
| 9 | |
| 10 | use g1t_contracts::repos::{BlobArgs, BlobView, EntryKind, GetArgs, Repo, RepoPath, TreeArgs, TreeView}; |
| 11 | use g1t_contracts::{FailureCode, Outcome, Viewer}; |
| 12 | use serde_json::{Value, json}; |
| 13 | use worker::Result; |
| 14 | |
| 15 | use crate::operations::Services; |
| 16 | |
| 17 | /// The most of a file's text one answer carries; more is cut with a note. |
| 18 | pub const MAX_TEXT_CHARS: usize = 200_000; |
| 19 | /// The most entries of a directory one answer lists. |
| 20 | pub const MAX_ENTRIES: usize = 1_000; |
| 21 | |
| 22 | pub fn description() -> &'static str { |
| 23 | "What a repository holds at a path, at `ref` (a branch, tag or commit; the default branch when left out). A file: its `text` (null for a binary file or one too large to show; at most 200,000 characters, the rest cut with a note), `size` in bytes and `html_url`. A directory (or the root, with no `path`): its `entries` (each `name`, `path` and `type`: dir, file, symlink or submodule) and its README's `text` when it has one. Read the root first to find README, CONTRIBUTING, package.json, Cargo.toml or a workflow under .g1t/workflows, then the files you need." |
| 24 | } |
| 25 | |
| 26 | pub fn input() -> Value { |
| 27 | json!({ |
| 28 | "type": "object", |
| 29 | "properties": { |
| 30 | "repo": { "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }, |
| 31 | "path": { "type": "string", "description": "A file or directory, such as README.md or src/lib; the root when left out." }, |
| 32 | "ref": { "type": "string", "description": "A branch, tag or commit; the default branch when left out." }, |
| 33 | }, |
| 34 | "required": ["repo"], |
| 35 | }) |
| 36 | } |
| 37 | |
| 38 | fn kind(entry: EntryKind) -> &'static str { |
| 39 | match entry { |
| 40 | EntryKind::Tree => "dir", |
| 41 | EntryKind::Blob | EntryKind::Exec => "file", |
| 42 | EntryKind::Symlink => "symlink", |
| 43 | EntryKind::Gitlink => "submodule", |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | fn full_name(repo: &Repo) -> String { |
| 48 | format!("{}/{}", repo.namespace, repo.name) |
| 49 | } |
| 50 | |
| 51 | /// A file's answer. |
| 52 | pub fn file(site: &str, view: &BlobView) -> Value { |
| 53 | let site = site.trim_end_matches('/'); |
| 54 | let text = view.text.as_ref().map(|text| { |
| 55 | let total = text.chars().count(); |
| 56 | if total <= MAX_TEXT_CHARS { |
| 57 | text.clone() |
| 58 | } else { |
| 59 | let kept: String = text.chars().take(MAX_TEXT_CHARS).collect(); |
| 60 | format!("{kept}\n[Cut: {} more characters.]", total - MAX_TEXT_CHARS) |
| 61 | } |
| 62 | }); |
| 63 | json!({ |
| 64 | "type": "file", |
| 65 | "repo": full_name(&view.repo), |
| 66 | "ref": view.git_ref, |
| 67 | "path": view.path, |
| 68 | "size": view.size, |
| 69 | "binary": text.is_none(), |
| 70 | "text": text, |
| 71 | "html_url": format!("{site}/{}/blob/{}/{}", full_name(&view.repo), view.git_ref, view.path.trim_start_matches('/')), |
| 72 | }) |
| 73 | } |
| 74 | |
| 75 | /// A directory's answer. |
| 76 | pub fn directory(site: &str, view: &TreeView) -> Value { |
| 77 | let site = site.trim_end_matches('/'); |
| 78 | let base = view.path.trim_matches('/'); |
| 79 | let entries: Vec<Value> = view |
| 80 | .entries |
| 81 | .iter() |
| 82 | .take(MAX_ENTRIES) |
| 83 | .map(|entry| { |
| 84 | let path = if base.is_empty() { entry.name.clone() } else { format!("{base}/{}", entry.name) }; |
| 85 | json!({ "name": entry.name, "path": path, "type": kind(entry.kind) }) |
| 86 | }) |
| 87 | .collect(); |
| 88 | let html_url = if base.is_empty() { |
| 89 | format!("{site}/{}", full_name(&view.repo)) |
| 90 | } else { |
| 91 | format!("{site}/{}/tree/{}/{base}", full_name(&view.repo), view.git_ref) |
| 92 | }; |
| 93 | json!({ |
| 94 | "type": "dir", |
| 95 | "repo": full_name(&view.repo), |
| 96 | "ref": view.git_ref, |
| 97 | "path": base, |
| 98 | "entries": entries, |
| 99 | "truncated": view.entries.len() > MAX_ENTRIES, |
| 100 | "readme": view.readme.as_ref().map(|readme| json!({ "name": readme.name, "text": readme.text })), |
| 101 | "html_url": html_url, |
| 102 | }) |
| 103 | } |
| 104 | |
| 105 | pub async fn run(services: &Services, viewer: &Viewer, repo: &RepoPath, input: &Value) -> Result<Outcome<Value>> { |
| 106 | let site = services.addresses.site.clone(); |
| 107 | let path = input["path"].as_str().unwrap_or_default().trim().trim_matches('/').to_owned(); |
| 108 | let git_ref = input["ref"].as_str().map(str::trim).filter(|git_ref| !git_ref.is_empty()).map(str::to_owned); |
| 109 | let tree = |tree_path: String, git_ref: Option<String>| TreeArgs { path: repo.clone(), viewer: viewer.clone(), git_ref, tree_path }; |
| 110 | if !path.is_empty() { |
| 111 | // A file needs a ref: the default branch, unless one was given. |
| 112 | let git_ref = match &git_ref { |
| 113 | Some(git_ref) => git_ref.clone(), |
| 114 | None => { |
| 115 | let found: Outcome<Repo> = |
| 116 | g1t_kit::call(&services.repos, "get", &GetArgs { path: repo.clone(), viewer: viewer.clone() }).await?; |
| 117 | match found { |
| 118 | Outcome::Ok(found) => found.default_branch, |
| 119 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 120 | } |
| 121 | } |
| 122 | }; |
| 123 | let blob: Outcome<BlobView> = g1t_kit::call( |
| 124 | &services.repos, |
| 125 | "blob", |
| 126 | &BlobArgs { path: repo.clone(), viewer: viewer.clone(), git_ref: git_ref.clone(), file_path: path.clone() }, |
| 127 | ) |
| 128 | .await?; |
| 129 | if let Outcome::Ok(view) = blob { |
| 130 | return Ok(Outcome::Ok(file(&site, &view))); |
| 131 | } |
| 132 | // Not a file: a directory, or nothing. |
| 133 | let listed: Outcome<TreeView> = g1t_kit::call(&services.repos, "tree", &tree(path.clone(), Some(git_ref))).await?; |
| 134 | return Ok(match listed { |
| 135 | Outcome::Ok(view) => Outcome::Ok(directory(&site, &view)), |
| 136 | Outcome::Fail(failure) if failure.code == FailureCode::NotFound => { |
| 137 | Outcome::fail(FailureCode::NotFound, format!("{}/{} has no file or directory {path}.", repo.namespace, repo.name)) |
| 138 | } |
| 139 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 140 | }); |
| 141 | } |
| 142 | let listed: Outcome<TreeView> = g1t_kit::call(&services.repos, "tree", &tree(String::new(), git_ref)).await?; |
| 143 | Ok(match listed { |
| 144 | Outcome::Ok(view) => Outcome::Ok(directory(&site, &view)), |
| 145 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 146 | }) |
| 147 | } |
| 148 | |
| 149 | #[cfg(test)] |
| 150 | mod tests { |
| 151 | use super::*; |
| 152 | use g1t_contracts::repos::{Readme, TreeEntry}; |
| 153 | |
| 154 | fn repo() -> Repo { |
| 155 | serde_json::from_value(json!({ |
| 156 | "id": "rep_1", "namespace": "acme", "name": "web", "description": null, "isPrivate": false, |
| 157 | "ownerId": "wsp_1", "defaultBranch": "main", "forkOf": null, "createdAt": "2026-01-01T00:00:00Z", |
| 158 | })) |
| 159 | .unwrap() |
| 160 | } |
| 161 | |
| 162 | #[test] |
| 163 | fn a_file_is_its_text_and_where_it_is() { |
| 164 | let view = BlobView { repo: repo(), git_ref: "main".into(), path: "CONTRIBUTING.md".into(), size: 12, text: Some("npm test\n".into()) }; |
| 165 | let shown = file("https://g1t.sh/", &view); |
| 166 | assert_eq!(shown["type"], "file"); |
| 167 | assert_eq!(shown["text"], "npm test\n"); |
| 168 | assert_eq!(shown["binary"], false); |
| 169 | assert_eq!(shown["html_url"], "https://g1t.sh/acme/web/blob/main/CONTRIBUTING.md"); |
| 170 | let binary = file("https://g1t.sh", &BlobView { text: None, ..view }); |
| 171 | assert_eq!(binary["binary"], true); |
| 172 | assert!(binary["text"].is_null()); |
| 173 | } |
| 174 | |
| 175 | #[test] |
| 176 | fn a_huge_file_is_cut_with_a_note() { |
| 177 | let text = "x".repeat(MAX_TEXT_CHARS + 10); |
| 178 | let view = BlobView { repo: repo(), git_ref: "main".into(), path: "big.txt".into(), size: text.len() as u64, text: Some(text) }; |
| 179 | let shown = file("https://g1t.sh", &view); |
| 180 | assert!(shown["text"].as_str().unwrap().ends_with("[Cut: 10 more characters.]")); |
| 181 | } |
| 182 | |
| 183 | #[test] |
| 184 | fn a_directory_lists_its_entries_and_readme() { |
| 185 | let view = TreeView { |
| 186 | repo: repo(), |
| 187 | git_ref: "main".into(), |
| 188 | path: "".into(), |
| 189 | head: None, |
| 190 | entries: vec![ |
| 191 | TreeEntry { name: "src".into(), hash: "a".into(), kind: EntryKind::Tree }, |
| 192 | TreeEntry { name: "README.md".into(), hash: "b".into(), kind: EntryKind::Blob }, |
| 193 | ], |
| 194 | readme: Some(Readme { name: "README.md".into(), text: Some("# Web".into()) }), |
| 195 | }; |
| 196 | let shown = directory("https://g1t.sh", &view); |
| 197 | assert_eq!(shown["entries"], json!([{ "name": "src", "path": "src", "type": "dir" }, { "name": "README.md", "path": "README.md", "type": "file" }])); |
| 198 | assert_eq!(shown["readme"]["text"], "# Web"); |
| 199 | assert_eq!(shown["html_url"], "https://g1t.sh/acme/web"); |
| 200 | let nested = directory("https://g1t.sh", &TreeView { path: "src".into(), readme: None, ..view }); |
| 201 | assert_eq!(nested["entries"][0]["path"], "src/src"); |
| 202 | assert_eq!(nested["html_url"], "https://g1t.sh/acme/web/tree/main/src"); |
| 203 | } |
| 204 | } |