Skip to main content

kowalski_core/
source_bundle.rs

1//! Assemble **raw source markdown** from URLs, local file paths, and free text.
2//!
3//! Used by federation / agent-app **worker** runtimes. This module has **no** dependency on any
4//! specific horde manifest — only paths you pass in (`root` / `workdir` for output layout).
5
6use crate::operator_input::parse_operator_answer_block;
7use crate::tools::internal::file_system::{self, DEFAULT_MAX_READ_BYTES};
8use crate::tools::internal::github::{fetch_url_for_ingest, GithubFetchKind, resolve_github_fetch};
9use crate::tools::internal::web::{fetch_url_as_markdown, html_to_markdown_at, looks_like_html};
10use chrono::Utc;
11use std::collections::{HashSet, VecDeque};
12use std::fs;
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum SourceToken {
17    Url(String),
18    FilePath(String),
19    Text(String),
20}
21
22fn trim_token(raw: &str) -> String {
23    raw.trim()
24        .trim_matches(|c: char| c == '"' || c == '\'' || c == '(' || c == ')')
25        .trim_end_matches([',', ';', ':', '.'])
26        .to_string()
27}
28
29/// Split CLI-style input into URL / existing file / fallback text.
30pub fn parse_source_tokens(input: &str) -> Vec<SourceToken> {
31    let mut out = Vec::new();
32    let mut seen = HashSet::new();
33    for token in input.split_whitespace() {
34        let t = trim_token(token);
35        if t.is_empty() {
36            continue;
37        }
38        let asset = if t.starts_with("http://") || t.starts_with("https://") {
39            SourceToken::Url(t.clone())
40        } else {
41            let p = Path::new(&t);
42            if p.is_file() {
43                SourceToken::FilePath(t.clone())
44            } else {
45                continue;
46            }
47        };
48        let key = format!("{:?}", asset);
49        if seen.insert(key) {
50            out.push(asset);
51        }
52    }
53    if out.is_empty() {
54        let t = input.trim();
55        if !t.is_empty() {
56            out.push(SourceToken::Text(t.to_string()));
57        }
58    }
59    out
60}
61
62fn slugify(input: &str) -> String {
63    let mut out = String::new();
64    let mut dash = false;
65    for ch in input.chars() {
66        let c = ch.to_ascii_lowercase();
67        if c.is_ascii_alphanumeric() {
68            out.push(c);
69            dash = false;
70        } else if !dash {
71            out.push('-');
72            dash = true;
73        }
74    }
75    out.trim_matches('-').to_string()
76}
77
78fn md_cell(input: &str) -> String {
79    input.replace('|', "\\|").replace('\n', " ")
80}
81
82fn normalize_fetched_url_body(text: &str, url: &str) -> String {
83    if looks_like_html(text) {
84        html_to_markdown_at(text, reqwest::Url::parse(url).ok().as_ref())
85    } else {
86        text.to_string()
87    }
88}
89
90/// **GitHub.com URLs** → [`fetch_url_for_ingest`](crate::tools::internal::github::fetch_url_for_ingest) (README API / raw / token).
91/// **All other HTTP(S) URLs** → [`fetch_url_as_markdown`](crate::tools::internal::web::fetch_url_as_markdown) (GET + HTML→MD when needed).
92/// If GitHub-specific fetch fails, falls back once to the web path.
93fn fetch_url_for_bundle(url: &str) -> Result<(String, String, String), String> {
94    let github_shape = resolve_github_fetch(url).is_some();
95    if github_shape
96        && let Ok(fetched) = fetch_url_for_ingest(url)
97    {
98            let via = match fetched.kind {
99                GithubFetchKind::ReadmeApi => "github readme api",
100                GithubFetchKind::RawUserContent => "github raw",
101                GithubFetchKind::PlainHttp => "github plain http",
102            };
103            let body = normalize_fetched_url_body(&fetched.text, url);
104            let note = if looks_like_html(&fetched.text) {
105                format!("{via}; html→md")
106            } else {
107                via.to_string()
108            };
109            return Ok((body, note, fetched.resolved_url));
110    }
111    let body = fetch_url_as_markdown(url).map_err(|e| e.to_string())?;
112    let note = if github_shape {
113        "web fetch (GitHub ingest failed or non-API body)".to_string()
114    } else {
115        "web fetch (non-GitHub URL)".to_string()
116    };
117    Ok((body, note, url.to_string()))
118}
119
120/// Limits when walking a local project directory for horde ingest.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct ProjectWalkConfig {
123    pub max_files: usize,
124    pub max_total_bytes: usize,
125    pub max_file_bytes: usize,
126    pub max_depth: usize,
127}
128
129impl Default for ProjectWalkConfig {
130    fn default() -> Self {
131        Self {
132            max_files: 200,
133            max_total_bytes: 2 * 1024 * 1024,
134            max_file_bytes: DEFAULT_MAX_READ_BYTES,
135            max_depth: 16,
136        }
137    }
138}
139
140/// One row in a project tree walk (manifest + optional inline content).
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ProjectWalkEntry {
143    pub rel_path: String,
144    pub bytes: u64,
145    pub included: bool,
146    pub note: String,
147}
148
149fn skip_project_dir(name: &str) -> bool {
150    matches!(
151        name,
152        ".git" | "target" | "node_modules" | ".cursor" | "dist" | "build" | "__pycache__"
153            | ".venv" | "venv" | "vendor" | "coverage" | ".next" | ".nuxt" | "tmp" | "output"
154    ) || (name.starts_with('.') && name != ".")
155}
156
157fn skip_project_file(name: &str) -> bool {
158    let lower = name.to_ascii_lowercase();
159    matches!(
160        lower.as_str(),
161        "cargo.lock"
162            | "package-lock.json"
163            | "yarn.lock"
164            | "pnpm-lock.yaml"
165    ) || lower.ends_with(".png")
166        || lower.ends_with(".jpg")
167        || lower.ends_with(".jpeg")
168        || lower.ends_with(".gif")
169        || lower.ends_with(".webp")
170        || lower.ends_with(".ico")
171        || lower.ends_with(".woff")
172        || lower.ends_with(".woff2")
173        || lower.ends_with(".ttf")
174        || lower.ends_with(".eot")
175        || lower.ends_with(".zip")
176        || lower.ends_with(".tar")
177        || lower.ends_with(".gz")
178        || lower.ends_with(".bin")
179        || lower.ends_with(".exe")
180        || lower.ends_with(".dylib")
181        || lower.ends_with(".so")
182        || lower.ends_with(".dll")
183        || lower.ends_with(".o")
184        || lower.ends_with(".a")
185}
186
187/// Walk `root` recursively (breadth-first), skipping common build/cache dirs and binary blobs.
188pub fn walk_project_tree(
189    root: &Path,
190    config: &ProjectWalkConfig,
191) -> Result<Vec<ProjectWalkEntry>, String> {
192    if !root.is_dir() {
193        return Err(format!("project root is not a directory: {}", root.display()));
194    }
195    let root = file_system::try_canonicalize(root);
196    let mut out = Vec::new();
197    let mut total_bytes: usize = 0;
198    let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::new();
199    queue.push_back((root.clone(), 0));
200
201    while let Some((dir, depth)) = queue.pop_front() {
202        if depth > config.max_depth {
203            continue;
204        }
205        let entries = fs::read_dir(&dir).map_err(|e| {
206            format!("read_dir {}: {}", dir.display(), e)
207        })?;
208        let mut names: Vec<_> = entries.flatten().collect();
209        names.sort_by_key(|e| e.file_name());
210
211        for entry in names {
212            if out.len() >= config.max_files {
213                out.push(ProjectWalkEntry {
214                    rel_path: "(truncated)".into(),
215                    bytes: 0,
216                    included: false,
217                    note: format!("max_files ({}) reached", config.max_files),
218                });
219                return Ok(out);
220            }
221            let path = entry.path();
222            let name = entry.file_name().to_string_lossy().into_owned();
223            if path.is_dir() {
224                if skip_project_dir(&name) {
225                    continue;
226                }
227                queue.push_back((path, depth + 1));
228                continue;
229            }
230            if !path.is_file() || skip_project_file(&name) {
231                continue;
232            }
233            let rel = path
234                .strip_prefix(&root)
235                .map(|p| p.to_string_lossy().into_owned())
236                .unwrap_or_else(|_| name.clone());
237            let bytes = file_system::file_len(&path).unwrap_or(0);
238            let (included, note) = if bytes > config.max_file_bytes as u64 {
239                (
240                    false,
241                    format!("skipped (file > {} bytes)", config.max_file_bytes),
242                )
243            } else if total_bytes.saturating_add(bytes as usize) > config.max_total_bytes {
244                (
245                    false,
246                    format!(
247                        "skipped (total budget {} bytes exhausted)",
248                        config.max_total_bytes
249                    ),
250                )
251            } else {
252                total_bytes = total_bytes.saturating_add(bytes as usize);
253                (true, "included".into())
254            };
255            out.push(ProjectWalkEntry {
256                rel_path: rel,
257                bytes,
258                included,
259                note,
260            });
261        }
262    }
263    Ok(out)
264}
265
266/// Resolve `project_path` from an operator prompt block (`answers_to_prompt` shape).
267pub fn extract_project_path_from_source(source: &str) -> Option<PathBuf> {
268    let answers = parse_operator_answer_block(source);
269    for (label, value) in &answers {
270        let ll = label.to_ascii_lowercase();
271        if ll.contains("project path") || ll == "project_path" {
272            let p = PathBuf::from(value.trim());
273            if p.is_dir() {
274                return Some(file_system::try_canonicalize(&p));
275            }
276        }
277    }
278    for token in source.split_whitespace() {
279        let t = trim_token(token);
280        if t.is_empty() {
281            continue;
282        }
283        let p = Path::new(&t);
284        if p.is_dir() && (p.is_absolute() || t.starts_with('.')) {
285            return Some(file_system::try_canonicalize(p));
286        }
287    }
288    None
289}
290
291fn render_project_tree_section(root: &Path, entries: &[ProjectWalkEntry]) -> String {
292    let mut doc = String::new();
293    doc.push_str("## Project tree\n\n");
294    doc.push_str(&format!(
295        "- Root: `{}`\n- Files indexed: {}\n- Files with inline content: {}\n\n",
296        root.display(),
297        entries.len(),
298        entries.iter().filter(|e| e.included).count()
299    ));
300    doc.push_str("| Path | Bytes | Status |\n");
301    doc.push_str("|---|---:|---|\n");
302    for e in entries {
303        if e.rel_path == "(truncated)" {
304            doc.push_str(&format!(
305                "| {} | — | {} |\n",
306                md_cell(&e.rel_path),
307                md_cell(&e.note)
308            ));
309            break;
310        }
311        doc.push_str(&format!(
312            "| {} | {} | {} |\n",
313            md_cell(&e.rel_path),
314            e.bytes,
315            md_cell(&e.note)
316        ));
317    }
318    doc.push_str("\n### Included file contents\n\n");
319    for e in entries.iter().filter(|e| e.included) {
320        let full = root.join(&e.rel_path);
321        let body = match file_system::read_file_bounded(&full, DEFAULT_MAX_READ_BYTES) {
322            Ok(s) => s.chars().take(24000).collect::<String>(),
323            Err(err) => format!("(unable to read: {err})"),
324        };
325        doc.push_str(&format!(
326            "<!-- project-file:{}:begin -->\n#### `{}`\n\n{}\n\n<!-- project-file:{}:end -->\n\n",
327            e.rel_path, e.rel_path, body, e.rel_path
328        ));
329    }
330    doc
331}
332
333/// Write `raw/<stamp>-inputs-N.md` under `root` from mixed URL / file / text input.
334///
335/// `root` is typically `workdir/debug`; bundled markdown lands in `workdir/debug/raw/`.
336pub fn write_raw_sources_markdown(
337    root: &Path,
338    source_input: &str,
339) -> Result<PathBuf, Box<dyn std::error::Error>> {
340    let assets = parse_source_tokens(source_input);
341    let project_root = extract_project_path_from_source(source_input);
342    let project_entries = project_root
343        .as_ref()
344        .map(|r| walk_project_tree(r, &ProjectWalkConfig::default()))
345        .transpose()
346        .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
347    let stamp = Utc::now().format("%Y%m%d-%H%M%S");
348    let asset_count = assets.len()
349        + usize::from(project_entries.as_ref().is_some_and(|e| !e.is_empty()));
350    let out = root
351        .join("raw")
352        .join(format!("{stamp}-inputs-{asset_count}.md"));
353    let now = Utc::now().to_rfc3339();
354    let mut doc = String::new();
355    doc.push_str("# Raw Inputs\n\n");
356    doc.push_str(&format!(
357        "- Inputs: {}\n- Ingested At: {}\n\n",
358        asset_count,
359        now
360    ));
361    if let Some(root) = &project_root {
362        doc.push_str(&format!("- Project root: `{}`\n", root.display()));
363    }
364    doc.push_str("## Sources Metadata\n\n");
365    doc.push_str("| # | Type | Source | Status | Chars | Notes |\n");
366    doc.push_str("|---:|---|---|---|---:|---|\n");
367
368    let mut sections = String::new();
369
370    for (idx, asset) in assets.iter().enumerate() {
371        match asset {
372            SourceToken::Url(url) => {
373                let section = match fetch_url_for_bundle(url) {
374                    Ok((body, note, resolved)) => {
375                        let clipped = body.chars().take(24000).collect::<String>();
376                        doc.push_str(&format!(
377                            "| {} | url | {} | ok | {} | {} |\n",
378                            idx + 1,
379                            md_cell(url),
380                            clipped.chars().count(),
381                            md_cell(&note),
382                        ));
383                        format!(
384                            "<!-- source:{}:url:begin -->\n## Source {}: URL\n\n- Original URL: `{}`\n- Resolved / peer: `{}`\n- Mode: {}\n\n{}\n\n<!-- source:{}:url:end -->\n\n",
385                            idx + 1,
386                            idx + 1,
387                            url,
388                            resolved,
389                            note,
390                            clipped,
391                            idx + 1
392                        )
393                    }
394                    Err(e) => {
395                        let err = format!("Fetch error: {}", e);
396                        doc.push_str(&format!(
397                            "| {} | url | {} | error | 0 | {} |\n",
398                            idx + 1,
399                            md_cell(url),
400                            md_cell(&err)
401                        ));
402                        format!(
403                            "<!-- source:{}:url:begin -->\n## Source {}: URL\n\n- URL: `{}`\n\n{}\n\n<!-- source:{}:url:end -->\n\n",
404                            idx + 1,
405                            idx + 1,
406                            url,
407                            err,
408                            idx + 1
409                        )
410                    }
411                };
412                sections.push_str(&section);
413            }
414            SourceToken::FilePath(path) => {
415                let content = match file_system::read_file_bounded(Path::new(path), DEFAULT_MAX_READ_BYTES)
416                {
417                    Ok(s) => s,
418                    Err(e) => format!("(unable to read file: {e})"),
419                };
420                let clipped = content.chars().take(24000).collect::<String>();
421                doc.push_str(&format!(
422                    "| {} | file | {} | ok | {} | local file |\n",
423                    idx + 1,
424                    path,
425                    clipped.chars().count()
426                ));
427                sections.push_str(&format!(
428                    "<!-- source:{}:file:begin -->\n## Source {}: File\n\n- Path: `{}`\n\n{}\n\n<!-- source:{}:file:end -->\n\n",
429                    idx + 1,
430                    idx + 1,
431                    path,
432                    clipped,
433                    idx + 1
434                ));
435            }
436            SourceToken::Text(text) => {
437                let slug = slugify(text);
438                doc.push_str(&format!(
439                    "| {} | text | {} | ok | {} | direct prompt text |\n",
440                    idx + 1,
441                    md_cell(if slug.is_empty() { "prompt" } else { &slug }),
442                    text.chars().count()
443                ));
444                sections.push_str(&format!(
445                    "<!-- source:{}:text:begin -->\n## Source {}: Text\n\n- Label: `{}`\n\n{}\n\n<!-- source:{}:text:end -->\n\n",
446                    idx + 1,
447                    idx + 1,
448                    if slug.is_empty() { "prompt" } else { &slug },
449                    text,
450                    idx + 1
451                ));
452            }
453        }
454    }
455    doc.push('\n');
456    doc.push_str("## Source Collection\n\n");
457    if let (Some(root), Some(entries)) = (project_root.as_ref(), project_entries.as_ref()) {
458        doc.push_str(&render_project_tree_section(root, entries));
459    }
460    doc.push_str(&sections);
461    if let Some(parent) = out.parent() {
462        fs::create_dir_all(parent)?;
463    }
464    fs::write(&out, doc)?;
465    Ok(out)
466}
467
468/// Alias for older naming (`InputAsset` in the CLI crate).
469pub type InputAsset = SourceToken;
470
471/// Back-compat: same as [`parse_source_tokens`].
472pub fn parse_input_assets(input: &str) -> Vec<InputAsset> {
473    parse_source_tokens(input)
474}
475
476/// Back-compat: same as [`write_raw_sources_markdown`].
477pub fn ingest_assets_markdown(
478    root: &Path,
479    source_input: &str,
480) -> Result<PathBuf, Box<dyn std::error::Error>> {
481    write_raw_sources_markdown(root, source_input)
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use std::fs;
488
489    #[test]
490    fn walk_skips_git_and_target() {
491        let dir = tempfile::tempdir().unwrap();
492        fs::create_dir_all(dir.path().join(".git/objects")).unwrap();
493        fs::create_dir_all(dir.path().join("target/debug")).unwrap();
494        fs::create_dir_all(dir.path().join("src")).unwrap();
495        fs::write(dir.path().join("src/lib.rs"), "fn main() {}").unwrap();
496        fs::write(dir.path().join("README.md"), "# hi").unwrap();
497        let entries = walk_project_tree(dir.path(), &ProjectWalkConfig::default()).unwrap();
498        let paths: Vec<_> = entries.iter().map(|e| e.rel_path.as_str()).collect();
499        assert!(paths.contains(&"src/lib.rs"));
500        assert!(paths.contains(&"README.md"));
501        assert!(!paths.iter().any(|p| p.contains(".git")));
502        assert!(!paths.iter().any(|p| p.contains("target")));
503    }
504
505    #[test]
506    fn extract_project_path_from_operator_block() {
507        let block = "# Operator input (Project intake)\n\n**Project path (local folder or repo root):** /tmp/kowalski\n\n**Task specification:** fix bugs\n";
508        let dir = tempfile::tempdir().unwrap();
509        let path = dir.path().to_string_lossy().to_string();
510        let block = block.replace("/tmp/kowalski", &path);
511        let got = extract_project_path_from_source(&block).unwrap();
512        assert_eq!(got, file_system::try_canonicalize(dir.path()));
513    }
514
515    #[test]
516    fn intake_includes_project_tree() {
517        let work = tempfile::tempdir().unwrap();
518        let project = tempfile::tempdir().unwrap();
519        fs::write(project.path().join("main.rs"), "fn main() {}").unwrap();
520        let source = format!(
521            "# Operator input\n\n**Project path (local folder or repo root):** {}\n\n**Task:** ship it\n",
522            project.path().display()
523        );
524        let out = write_raw_sources_markdown(work.path(), &source).unwrap();
525        let body = fs::read_to_string(out).unwrap();
526        assert!(body.contains("## Project tree"));
527        assert!(body.contains("main.rs"));
528        assert!(body.contains("fn main()"));
529    }
530}