#[derive(Debug, Clone, PartialEq)]
pub struct Tile {
pub question: String,
pub answer: String,
pub tags: Vec<String>,
pub domain: String,
}
impl Tile {
pub fn new(question: impl Into<String>, answer: impl Into<String>) -> Self {
Self {
question: question.into(),
answer: answer.into(),
tags: vec![],
domain: String::new(),
}
}
}
pub fn import_markdown(md: &str) -> Vec<Tile> {
let mut tiles = Vec::new();
let mut current_q: Option<String> = None;
let mut current_body = String::new();
let flush = |current_q: &mut Option<String>, current_body: &mut String, tiles: &mut Vec<Tile>| {
if let Some(q) = current_q.take() {
let body = current_body.trim().to_string();
if !body.is_empty() {
let tags = extract_bracketed_tags(&body);
let domain = q.split_whitespace().next().unwrap_or("").to_lowercase();
tiles.push(Tile {
question: q,
answer: body,
tags,
domain,
});
}
}
current_body.clear();
};
for line in md.lines() {
if let Some(header) = line.strip_prefix("## ") {
flush(&mut current_q, &mut current_body, &mut tiles);
current_q = Some(header.trim().to_string());
} else if current_q.is_some() {
if !current_body.is_empty() {
current_body.push('\n');
}
current_body.push_str(line);
}
}
flush(&mut current_q, &mut current_body, &mut tiles);
tiles
}
fn extract_bracketed_tags(s: &str) -> Vec<String> {
let mut tags = Vec::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '[' {
let mut tag = String::new();
let mut closed = false;
for inner in chars.by_ref() {
if inner == ']' {
closed = true;
break;
}
tag.push(inner);
}
if closed && !tag.is_empty() && tag.split_whitespace().count() == 1 {
tags.push(tag);
}
}
}
tags
}
pub fn import_json(json: &str) -> Vec<Tile> {
let mut tiles = Vec::new();
let trimmed = json.trim();
if !trimmed.starts_with('[') {
return tiles;
}
let mut depth = 0i32;
let mut current = String::new();
let mut in_string = false;
let mut escape_next = false;
for c in trimmed.chars() {
if escape_next {
escape_next = false;
if depth >= 1 {
current.push(c);
}
continue;
}
if in_string {
if c == '\\' {
escape_next = true;
if depth >= 1 {
current.push(c);
}
continue;
}
if c == '"' {
in_string = false;
}
if depth >= 1 {
current.push(c);
}
continue;
}
match c {
'"' => {
in_string = true;
if depth >= 1 {
current.push(c);
}
}
'{' => {
depth += 1;
if depth > 1 {
current.push(c);
}
}
'}' => {
depth -= 1;
if depth == 0 {
let obj = current.trim().to_string();
current.clear();
if let Some(tile) = parse_json_object(&obj) {
tiles.push(tile);
}
} else {
current.push(c);
}
}
_ => {
if depth >= 1 {
current.push(c);
}
}
}
}
tiles
}
fn parse_json_object(obj: &str) -> Option<Tile> {
let question = json_string_value(obj, "question")?;
let answer = json_string_value(obj, "answer")?;
let domain = json_string_value(obj, "domain").unwrap_or_default();
let tags = json_string_array(obj, "tags");
Some(Tile { question, answer, tags, domain })
}
fn json_string_value(obj: &str, key: &str) -> Option<String> {
let needle = format!("\"{}\"", key);
let start = obj.find(&needle)?;
let after_key = &obj[start + needle.len()..];
let after_colon = after_key.trim_start().strip_prefix(':')?.trim_start();
if !after_colon.starts_with('"') {
return None;
}
let content = &after_colon[1..];
let mut out = String::new();
let mut chars = content.chars();
loop {
match chars.next()? {
'\\' => {
match chars.next()? {
'n' => out.push('\n'),
't' => out.push('\t'),
other => out.push(other),
}
}
'"' => break,
c => out.push(c),
}
}
Some(out)
}
fn json_string_array(obj: &str, key: &str) -> Vec<String> {
let needle = format!("\"{}\"", key);
let start = match obj.find(&needle) {
Some(s) => s,
None => return vec![],
};
let after_key = &obj[start + needle.len()..];
let after_colon = match after_key.trim_start().strip_prefix(':') {
Some(s) => s.trim_start(),
None => return vec![],
};
if !after_colon.starts_with('[') {
return vec![];
}
let inner_start = 1;
let end = match after_colon.find(']') {
Some(e) => e,
None => return vec![],
};
let array_body = &after_colon[inner_start..end];
let mut result = Vec::new();
let mut chars = array_body.chars().peekable();
while let Some(c) = chars.next() {
if c == '"' {
let mut s = String::new();
let mut closed = false;
loop {
match chars.next() {
None => break,
Some('\\') => {
if let Some(e) = chars.next() {
s.push(e);
}
}
Some('"') => { closed = true; break; }
Some(other) => s.push(other),
}
}
if closed {
result.push(s);
}
}
}
result
}
pub fn import_csv(csv: &str) -> Vec<Tile> {
let mut tiles = Vec::new();
let mut lines = csv.lines();
let header_line = loop {
match lines.next() {
None => return tiles,
Some(l) if !l.trim().is_empty() => break l,
_ => {}
}
};
let headers: Vec<String> = csv_parse_row(header_line)
.into_iter()
.map(|h| h.trim().to_lowercase())
.collect();
let question_idx = headers.iter().position(|h| h == "question");
let answer_idx = headers.iter().position(|h| h == "answer");
let domain_idx = headers.iter().position(|h| h == "domain");
let (qi, ai) = match (question_idx, answer_idx) {
(Some(q), Some(a)) => (q, a),
_ => return tiles,
};
for line in lines {
let line = line.trim();
if line.is_empty() {
continue;
}
let fields = csv_parse_row(line);
let question = match fields.get(qi) {
Some(v) => v.trim().to_string(),
None => continue,
};
let answer = match fields.get(ai) {
Some(v) => v.trim().to_string(),
None => continue,
};
if question.is_empty() {
continue;
}
let domain = domain_idx
.and_then(|di| fields.get(di))
.map(|d| d.trim().to_string())
.unwrap_or_default();
tiles.push(Tile { question, answer, tags: vec![], domain });
}
tiles
}
fn csv_parse_row(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut field = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if in_quotes => {
if chars.peek() == Some(&'"') {
chars.next();
field.push('"');
} else {
in_quotes = false;
}
}
'"' => {
in_quotes = true;
}
',' if !in_quotes => {
fields.push(field.clone());
field.clear();
}
other => {
field.push(other);
}
}
}
fields.push(field);
fields
}
pub fn import_plaintext(text: &str) -> Vec<Tile> {
let mut tiles = Vec::new();
let paragraphs: Vec<&str> = text.split("\n\n").collect();
for para in paragraphs {
let mut non_empty_lines: Vec<&str> = para.lines().filter(|l| !l.trim().is_empty()).collect();
if non_empty_lines.len() < 2 {
continue;
}
let question = non_empty_lines.remove(0).trim().to_string();
let answer = non_empty_lines.join(" ").trim().to_string();
if answer.is_empty() {
continue;
}
tiles.push(Tile::new(question, answer));
}
tiles
}
pub fn export_markdown(tiles: &[Tile]) -> String {
tiles
.iter()
.map(|t| format!("## {}\n{}\n\n", t.question, t.answer))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_markdown_parses_sections() {
let md = "## What is Rust?\nA systems programming language.\n\n## What is cargo?\nThe Rust package manager.";
let tiles = import_markdown(md);
assert_eq!(tiles.len(), 2);
assert_eq!(tiles[0].question, "What is Rust?");
assert_eq!(tiles[0].answer, "A systems programming language.");
assert_eq!(tiles[1].question, "What is cargo?");
assert_eq!(tiles[1].answer, "The Rust package manager.");
}
#[test]
fn test_markdown_skips_empty_sections() {
let md = "## Empty Section\n\n## Real Section\nHas content here.";
let tiles = import_markdown(md);
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].question, "Real Section");
}
#[test]
fn test_markdown_extracts_bracketed_tags() {
let md = "## What is [Rust]?\nA language. See [Systems] and [Memory] safety.";
let tiles = import_markdown(md);
assert_eq!(tiles.len(), 1);
assert!(tiles[0].tags.contains(&"Systems".to_string()));
assert!(tiles[0].tags.contains(&"Memory".to_string()));
}
#[test]
fn test_markdown_domain_from_first_word() {
let md = "## Ownership in Rust\nCore memory concept.";
let tiles = import_markdown(md);
assert_eq!(tiles[0].domain, "ownership");
}
#[test]
fn test_markdown_multiline_answer() {
let md = "## Question\nLine one.\nLine two.\nLine three.";
let tiles = import_markdown(md);
assert_eq!(tiles.len(), 1);
assert!(tiles[0].answer.contains("Line one."));
assert!(tiles[0].answer.contains("Line two."));
assert!(tiles[0].answer.contains("Line three."));
}
#[test]
fn test_json_parses_basic_array() {
let json = r#"[{"question":"What is 2+2?","answer":"4","domain":"math"}]"#;
let tiles = import_json(json);
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].question, "What is 2+2?");
assert_eq!(tiles[0].answer, "4");
assert_eq!(tiles[0].domain, "math");
}
#[test]
fn test_json_handles_missing_optional_fields() {
let json = r#"[{"question":"Q?","answer":"A."}]"#;
let tiles = import_json(json);
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].domain, "");
assert!(tiles[0].tags.is_empty());
}
#[test]
fn test_json_skips_objects_without_question_or_answer() {
let json = r#"[{"domain":"math"},{"question":"Q?","answer":"A."}]"#;
let tiles = import_json(json);
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].question, "Q?");
}
#[test]
fn test_json_parses_tags_array() {
let json = r#"[{"question":"Q","answer":"A","tags":["tag1","tag2"]}]"#;
let tiles = import_json(json);
assert_eq!(tiles[0].tags, vec!["tag1", "tag2"]);
}
#[test]
fn test_csv_parses_header_and_rows() {
let csv = "question,answer,domain\nWhat is Rust?,A systems language,programming\nWhat is cargo?,Package manager,tooling";
let tiles = import_csv(csv);
assert_eq!(tiles.len(), 2);
assert_eq!(tiles[0].question, "What is Rust?");
assert_eq!(tiles[0].answer, "A systems language");
assert_eq!(tiles[0].domain, "programming");
}
#[test]
fn test_csv_quoted_fields() {
let csv = "question,answer\n\"Question, with comma\",\"Answer, with comma\"";
let tiles = import_csv(csv);
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].question, "Question, with comma");
assert_eq!(tiles[0].answer, "Answer, with comma");
}
#[test]
fn test_plaintext_splits_on_double_newlines() {
let text = "Question one\nAnswer one\n\nQuestion two\nAnswer two";
let tiles = import_plaintext(text);
assert_eq!(tiles.len(), 2);
}
#[test]
fn test_plaintext_first_line_is_question() {
let text = "What is the sky?\nBlue.";
let tiles = import_plaintext(text);
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].question, "What is the sky?");
assert_eq!(tiles[0].answer, "Blue.");
}
#[test]
fn test_plaintext_skips_paragraphs_with_no_answer() {
let text = "Lonely header\n\nReal Question\nReal Answer";
let tiles = import_plaintext(text);
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].question, "Real Question");
}
#[test]
fn test_export_markdown_format() {
let tiles = vec![Tile::new("What is pi?", "Approximately 3.14159.")];
let md = export_markdown(&tiles);
assert!(md.contains("## What is pi?"));
assert!(md.contains("Approximately 3.14159."));
}
#[test]
fn test_export_markdown_roundtrip_with_import() {
let original = vec![
Tile::new("What is Rust?", "A systems language."),
Tile::new("What is cargo?", "The package manager."),
];
let md = export_markdown(&original);
let imported = import_markdown(&md);
assert_eq!(imported.len(), 2);
assert_eq!(imported[0].question, original[0].question);
assert_eq!(imported[0].answer, original[0].answer);
assert_eq!(imported[1].question, original[1].question);
assert_eq!(imported[1].answer, original[1].answer);
}
#[test]
fn test_import_export_markdown_consistency() {
let md = "## Alpha\nFirst answer.\n\n## Beta\nSecond answer.\n\n";
let tiles = import_markdown(md);
let re_exported = export_markdown(&tiles);
let re_imported = import_markdown(&re_exported);
assert_eq!(tiles.len(), re_imported.len());
for (a, b) in tiles.iter().zip(re_imported.iter()) {
assert_eq!(a.question, b.question);
assert_eq!(a.answer, b.answer);
}
}
}