use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct GeneratedFile {
pub path: PathBuf,
pub content: String,
pub language: String,
}
pub fn extract_files(raw: &str, default_language: &str) -> Vec<GeneratedFile> {
let mut files = Vec::new();
let mut pending_path: Option<String> = None;
let mut in_fence = false;
let mut fence_lang = String::new();
let mut fence_content = String::new();
for line in raw.lines() {
let trimmed = line.trim();
if !in_fence && trimmed.starts_with("```") {
in_fence = true;
fence_lang = trimmed.trim_start_matches('`').trim().to_string();
fence_content.clear();
continue;
}
if in_fence && trimmed == "```" {
in_fence = false;
let content = fence_content.trim().to_string();
if content.is_empty() {
pending_path = None;
continue;
}
let file_path = if let Some(ref p) = pending_path {
p.clone()
} else {
extract_path_from_first_line(&content).unwrap_or_default()
};
if !file_path.is_empty() {
let file_path = file_path
.trim_start_matches(['#', '*', ' ', '`'])
.to_string();
if !file_path.is_empty() && looks_like_path(&file_path) {
let lang = if fence_lang.is_empty() {
detect_lang_from_path(&file_path)
.unwrap_or_else(|| default_language.to_string())
} else {
fence_lang.clone()
};
let clean_content = strip_filepath_comment(&content, &file_path);
let clean_content = strip_inner_code_fences(&clean_content, &file_path);
files.push(GeneratedFile {
path: PathBuf::from(&file_path),
content: clean_content,
language: lang,
});
}
}
pending_path = None;
continue;
}
if in_fence {
fence_content.push_str(line);
fence_content.push('\n');
continue;
}
if let Some(path) = extract_path_from_header(trimmed) {
pending_path = Some(path);
}
}
files
}
fn extract_path_from_header(line: &str) -> Option<String> {
let trimmed = line.trim();
for prefix in &["# filepath:", "// filepath:", "filepath:"] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
let path = rest.trim().trim_matches('`');
if looks_like_path(path) {
return Some(path.to_string());
}
}
}
if trimmed.starts_with("<!--") && trimmed.contains("file:") {
if let Some(rest) = trimmed.split("file:").nth(1) {
let path = rest.trim().trim_end_matches("-->").trim().trim_matches('`');
if looks_like_path(path) {
return Some(path.to_string());
}
}
}
if trimmed.starts_with('#') {
let after_hashes = trimmed.trim_start_matches('#').trim();
if looks_like_path(after_hashes) {
let path = after_hashes.trim_matches('`').trim_matches('*');
return Some(path.to_string());
}
}
if trimmed.starts_with("**") && trimmed.ends_with("**") {
let inner = &trimmed[2..trimmed.len() - 2];
if looks_like_path(inner) {
return Some(inner.to_string());
}
}
if let Some(rest) = trimmed.strip_prefix("File:") {
let path = rest.trim().trim_matches('`');
if looks_like_path(path) {
return Some(path.to_string());
}
}
None
}
fn extract_path_from_first_line(content: &str) -> Option<String> {
let first_line = content.lines().next()?.trim();
if let Some(rest) = first_line.strip_prefix('#') {
let path = rest.trim();
if looks_like_path(path) {
return Some(path.to_string());
}
}
if let Some(rest) = first_line.strip_prefix("//") {
let path = rest.trim();
if looks_like_path(path) {
return Some(path.to_string());
}
}
None
}
fn strip_filepath_comment(content: &str, path: &str) -> String {
let mut lines = content.lines();
if let Some(first_line) = lines.next() {
let trimmed = first_line.trim();
if trimmed.contains(path) && (trimmed.starts_with('#') || trimmed.starts_with("//")) {
return lines.collect::<Vec<_>>().join("\n").trim().to_string();
}
}
content.to_string()
}
fn strip_inner_code_fences(content: &str, path: &str) -> String {
let trimmed = content.trim();
if !trimmed.starts_with("```") {
return content.to_string();
}
let config_extensions = [
".toml", ".yaml", ".yml", ".json", ".ini", ".cfg", ".env", ".md", ".txt", ".html", ".css",
".sql", ".sh",
];
if !config_extensions.iter().any(|ext| path.ends_with(ext)) {
return content.to_string();
}
let first_newline = trimmed.find('\n').unwrap_or(0);
let after_fence = &trimmed[first_newline + 1..];
let stripped = if after_fence.trim_end().ends_with("```") {
let end = after_fence.rfind("```").unwrap_or(after_fence.len());
&after_fence[..end]
} else {
after_fence
};
stripped.trim().to_string()
}
fn looks_like_path(s: &str) -> bool {
let s = s.trim();
if s.is_empty() || s.len() > 200 {
return false;
}
if s == "path/to/file.py" || s.starts_with("path/to/") || s == "file.py" {
return false;
}
if !s.contains('.') {
return false;
}
if s.contains("..") {
return false;
}
if s.starts_with('/') {
return false;
}
let extensions = [
".py", ".rs", ".ts", ".tsx", ".js", ".jsx", ".go", ".java", ".toml", ".yaml", ".yml",
".json", ".md", ".txt", ".html", ".css", ".sql", ".sh", ".cfg", ".ini", ".env",
];
extensions.iter().any(|ext| s.ends_with(ext))
}
fn detect_lang_from_path(path: &str) -> Option<String> {
if path.ends_with(".py") {
Some("python".into())
} else if path.ends_with(".ts") || path.ends_with(".tsx") {
Some("typescript".into())
} else if path.ends_with(".js") || path.ends_with(".jsx") {
Some("javascript".into())
} else if path.ends_with(".rs") {
Some("rust".into())
} else if path.ends_with(".go") {
Some("go".into())
} else {
None
}
}
pub fn write_files(output_dir: &Path, files: &[GeneratedFile]) -> Result<Vec<PathBuf>> {
let mut written = Vec::new();
for file in files {
let path_str = file.path.display().to_string();
let full_path = match crate::sandbox::validate_path_within(output_dir, &path_str) {
Ok(p) => p,
Err(e) => {
eprintln!("[SECURITY] Skipping file write: {}", e);
continue;
}
};
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create dir for {}", file.path.display()))?;
}
fs::write(&full_path, &file.content)
.with_context(|| format!("Failed to write {}", file.path.display()))?;
written.push(full_path);
}
Ok(written)
}
pub fn write_boilerplate(output_dir: &Path, language: &str, prompt: &str) -> Result<()> {
if language == "python" {
create_init_files(output_dir)?;
if !output_dir.join("requirements.txt").exists() {
fs::write(
output_dir.join("requirements.txt"),
"fastapi\nuvicorn[standard]\nPyJWT\ncryptography\npydantic[email]\npython-multipart\nslowapi\npytest\nhttpx\npasslib[bcrypt]\npython-jose[cryptography]\nsqlalchemy\n",
)?;
}
if !output_dir.join("Dockerfile").exists() {
fs::write(
output_dir.join("Dockerfile"),
"FROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt .\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY . .\nEXPOSE 8000\nCMD [\"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\"]\n",
)?;
}
}
if !output_dir.join("README.md").exists() {
fs::write(
output_dir.join("README.md"),
format!(
"# Generated by BattleCommand Forge v1.1\n\n**Prompt:** {}\n\n**Quality gate:** >= 9.2/10\n",
prompt
),
)?;
}
Ok(())
}
fn create_init_files(dir: &Path) -> Result<()> {
if !dir.is_dir() {
return Ok(());
}
let has_py = fs::read_dir(dir)?.any(|e| {
e.ok()
.map(|e| e.path().extension().map(|ext| ext == "py").unwrap_or(false))
.unwrap_or(false)
});
if has_py {
let init = dir.join("__init__.py");
if !init.exists() {
fs::write(&init, "")?;
}
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
if entry.path().is_dir() {
create_init_files(&entry.path())?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_files_with_filepath_headers() {
let raw = "\
Here are the files:
### app/main.py
```python
from fastapi import FastAPI
app = FastAPI()
```
### app/config.py
```python
import os
SECRET = os.getenv(\"SECRET\")
```
";
let files = extract_files(raw, "python");
assert_eq!(files.len(), 2);
assert_eq!(files[0].path, PathBuf::from("app/main.py"));
assert!(files[0].content.contains("FastAPI"));
assert_eq!(files[1].path, PathBuf::from("app/config.py"));
assert!(files[1].content.contains("SECRET"));
}
#[test]
fn test_extract_files_with_inline_path() {
let raw = "\
```python
# app/models.py
from sqlalchemy import Column
class User:
pass
```
";
let files = extract_files(raw, "python");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, PathBuf::from("app/models.py"));
assert!(!files[0].content.starts_with("# app/models.py"));
}
#[test]
fn test_extract_files_bold_header() {
let raw = "\
**app/utils.py**
```python
def helper():
return 42
```
";
let files = extract_files(raw, "python");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, PathBuf::from("app/utils.py"));
}
#[test]
fn test_looks_like_path() {
assert!(looks_like_path("app/main.py"));
assert!(looks_like_path("src/index.ts"));
assert!(!looks_like_path("just some text"));
assert!(!looks_like_path("../etc/passwd"));
assert!(!looks_like_path("/root/file.py"));
}
#[test]
fn test_fallback_single_fence_no_path() {
let raw = "```python\nprint('hello')\n```";
let files = extract_files(raw, "python");
assert_eq!(files.len(), 0);
}
#[test]
fn test_file_prefix_header() {
let raw = "File: `app/routes.py`\n\n```python\n@app.get('/')\ndef root():\n pass\n```";
let files = extract_files(raw, "python");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, PathBuf::from("app/routes.py"));
}
}