1use 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
29pub 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
90fn 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#[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#[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
187pub 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
266pub 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
333pub 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(¬e),
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(§ion);
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(§ions);
461 if let Some(parent) = out.parent() {
462 fs::create_dir_all(parent)?;
463 }
464 fs::write(&out, doc)?;
465 Ok(out)
466}
467
468pub type InputAsset = SourceToken;
470
471pub fn parse_input_assets(input: &str) -> Vec<InputAsset> {
473 parse_source_tokens(input)
474}
475
476pub 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}