flq 1.0.0

fetch the web as lean, token-efficient text
Documentation
use crate::extract::{Block, Doc};

pub fn render_body(doc: &Doc) -> String {
	let mut s = String::new();
	for b in &doc.blocks {
		match b {
			Block::Heading(lv, t) => {
				s.push_str(&"#".repeat(*lv as usize));
				s.push(' ');
				s.push_str(t);
			}
			Block::Para(t) => s.push_str(t),
			Block::Item(depth, t) => {
				s.push_str(&"  ".repeat(*depth as usize));
				s.push_str("- ");
				s.push_str(t);
			}
			Block::Quote(t) => {
				s.push_str("> ");
				s.push_str(t);
			}
			Block::Code(t) => {
				s.push_str("```\n");
				s.push_str(t);
				s.push_str("\n```");
			}
			Block::Link(href, t) => {
				s.push_str("@ ");
				s.push_str(href);
				s.push_str(" | ");
				s.push_str(t);
			}
			Block::Img(src, alt) => {
				s.push_str("! ");
				s.push_str(src);
				if !alt.is_empty() {
					s.push_str(" | ");
					s.push_str(alt);
				}
			}
			Block::Table { headers, rows } => {
				render_table(&mut s, headers, rows);
			}
		}
		s.push('\n');
	}
	s
}

fn render_table(s: &mut String, headers: &[String], rows: &[Vec<String>]) {
	let n = rows.len();
	if headers.is_empty() {
		s.push_str(&format!("table[{n}]:\n"));
	} else {
		let hdr = headers.join(",");
		s.push_str(&format!("table[{n}]{{{hdr}}}:\n"));
	}
	for row in rows {
		s.push_str(&escape_row(row));
		s.push('\n');
	}
}

fn escape_row(cells: &[String]) -> String {
	cells
		.iter()
		.map(|c| {
			if c.contains(',') || c.contains('"') {
				let escaped = c.replace('"', "\"\"");
				format!("\"{escaped}\"")
			} else {
				c.clone()
			}
		})
		.collect::<Vec<_>>()
		.join(",")
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::extract::Block;

	fn body(blocks: Vec<Block>) -> String {
		render_body(&Doc {
			title: String::new(),
			url: String::new(),
			lang: String::new(),
			desc: String::new(),
			blocks,
		})
	}

	fn s(x: &str) -> String {
		x.to_string()
	}

	#[test]
	fn heading_levels() {
		assert_eq!(body(vec![Block::Heading(1, s("Top"))]), "# Top\n");
		assert_eq!(body(vec![Block::Heading(3, s("Sub"))]), "### Sub\n");
	}

	#[test]
	fn paragraph_and_quote() {
		assert_eq!(body(vec![Block::Para(s("hello"))]), "hello\n");
		assert_eq!(body(vec![Block::Quote(s("cited"))]), "> cited\n");
	}

	#[test]
	fn list_items_indent_by_depth() {
		let out = body(vec![
			Block::Item(0, s("a")),
			Block::Item(1, s("b")),
			Block::Item(2, s("c")),
		]);
		assert_eq!(out, "- a\n  - b\n    - c\n");
	}

	#[test]
	fn code_is_fenced() {
		assert_eq!(
			body(vec![Block::Code(s("let x = 1;"))]),
			"```\nlet x = 1;\n```\n"
		);
	}

	#[test]
	fn link_format() {
		assert_eq!(
			body(vec![Block::Link(s("https://x.com"), s("label"))]),
			"@ https://x.com | label\n"
		);
	}

	#[test]
	fn img_with_and_without_alt() {
		assert_eq!(
			body(vec![Block::Img(s("https://x/a.png"), s("alt"))]),
			"! https://x/a.png | alt\n"
		);
		assert_eq!(
			body(vec![Block::Img(s("https://x/a.png"), s(""))]),
			"! https://x/a.png\n"
		);
	}

	#[test]
	fn table_with_headers() {
		let t = Block::Table {
			headers: vec![s("name"), s("price")],
			rows: vec![vec![s("a"), s("100")], vec![s("b"), s("200")]],
		};
		assert_eq!(body(vec![t]), "table[2]{name,price}:\na,100\nb,200\n\n");
	}

	#[test]
	fn table_without_headers() {
		let t = Block::Table {
			headers: vec![],
			rows: vec![vec![s("x")], vec![s("y")]],
		};
		assert_eq!(body(vec![t]), "table[2]:\nx\ny\n\n");
	}

	#[test]
	fn cell_with_comma_is_quoted() {
		let t = Block::Table {
			headers: vec![],
			rows: vec![vec![s("a,b"), s("c")]],
		};
		assert_eq!(body(vec![t]), "table[1]:\n\"a,b\",c\n\n");
	}

	#[test]
	fn cell_with_quote_is_escaped() {
		let t = Block::Table {
			headers: vec![],
			rows: vec![vec![s(r#"say "hi""#)]],
		};
		assert_eq!(body(vec![t]), "table[1]:\n\"say \"\"hi\"\"\"\n\n");
	}
}