From d81d2d304a509af62fadccc39ee0e6678e457834 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Sun, 10 May 2026 20:56:10 +0200 Subject: [PATCH 1/7] better error reporting --- src/main.rs | 79 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/src/main.rs b/src/main.rs index f3806ad..2293598 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,35 +1,78 @@ use flakers::{parse_entry, parse_header}; -use nom::{Parser, multi::many0}; use std::io::{self, Read}; +use std::process::ExitCode; -fn main() { +fn main() -> ExitCode { let mut input = String::new(); #[allow(clippy::expect_used)] io::stdin() .read_to_string(&mut input) .expect("Failed to read stdin"); + println!("
Raw output

"); + println!("\n```"); + println!("{}", input.trim()); + println!("```"); + println!("\n

\n"); + let remaining = match parse_header(&input) { Ok((remaining, _)) => remaining, Err(e) => { - eprintln!("Failed to parse header: {}", e); - std::process::exit(1); + println!("
Parse errors

"); + println!("\n```"); + println!("Failed to parse header: {e}"); + println!("```"); + println!("\n

\n"); + return ExitCode::FAILURE; } }; - let entries = match many0(parse_entry).parse(remaining) { - Ok((_, entries)) => entries, - Err(e) => { - eprintln!("Failed to parse entries: {}", e); - std::process::exit(1); + let mut current = remaining; + let mut entries = Vec::new(); + let mut errors: Vec = Vec::new(); + + loop { + if current.trim().is_empty() { + break; } - }; + match parse_entry(current) { + Ok((rest, entry)) => { + entries.push(entry); + current = rest; + } + Err(_) => { + let offset = current.as_ptr() as usize - input.as_ptr() as usize; + let line_num = input[..offset].lines().count() + 1; + let next = current + .split_inclusive('\n') + .skip(1) + .find(|line| line.starts_with('•')) + .map(|line| { + let offset = line.as_ptr() as usize - current.as_ptr() as usize; + ¤t[offset..] + }); + let bad_chunk = match next { + Some(rest) => ¤t[..current.len() - rest.len()], + None => current, + }; + errors.push(format!("line {line_num}:\n{}", bad_chunk.trim())); + match next { + Some(rest) => current = rest, + None => break, + } + } + } + } - println!("
Raw output

"); - println!("\n```"); - print!("{}", input); - println!("```"); - println!("\n

\n"); + if !errors.is_empty() { + println!("
Parse errors

"); + println!("\n```"); + for error in &errors { + println!("{error}"); + } + println!("```"); + println!("\n

\n"); + } entries .iter() @@ -39,4 +82,10 @@ fn main() { .iter() .filter(|e| matches!(e, flakers::Entry::Updated(_, _))) .for_each(|e| println!("{}", e.summary())); + + if errors.is_empty() { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } } From 5d8e33691f8e282e12d68944a3f865a9ed529d7f Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Sun, 10 May 2026 21:25:17 +0200 Subject: [PATCH 2/7] helpful errors --- src/lib.rs | 163 +++++++++++++++++++++++++++++++++++++++------------- src/main.rs | 22 ++++++- 2 files changed, 143 insertions(+), 42 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index fde4f27..f8ce7a2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,9 +4,59 @@ use nom::{ bytes::complete::{tag, take_until, take_while1}, character::complete::{char, line_ending, not_line_ending, space0, space1}, combinator::{opt, verify}, + error::{ContextError, ErrorKind, ParseError, context}, sequence::delimited, }; +type IRes<'a, T> = IResult<&'a str, T, Error<'a>>; + +/// Error type that captures the input position and the innermost context label. +#[derive(Debug)] +pub struct Error<'a> { + pub input: &'a str, + pub context: Option<&'static str>, +} + +impl<'a> ParseError<&'a str> for Error<'a> { + fn from_error_kind(input: &'a str, _kind: ErrorKind) -> Self { + Error { + input, + context: None, + } + } + + fn append(_input: &'a str, _kind: ErrorKind, other: Self) -> Self { + other + } + + fn or(self, other: Self) -> Self { + // keep the error that consumed more input (shorter remaining = more progress) + if self.input.len() <= other.input.len() { + self + } else { + other + } + } +} + +impl<'a> ContextError<&'a str> for Error<'a> { + fn add_context(_input: &'a str, ctx: &'static str, other: Self) -> Self { + Error { + input: other.input, + context: Some(other.context.unwrap_or(ctx)), + } + } +} + +impl<'a, E> nom::error::FromExternalError<&'a str, E> for Error<'a> { + fn from_external_error(input: &'a str, _kind: ErrorKind, _e: E) -> Self { + Error { + input, + context: None, + } + } +} + #[derive(Debug, PartialEq)] enum FlakeRefType { Github, @@ -22,7 +72,7 @@ struct FlakeRef<'a> { } impl<'a> FlakeRef<'a> { - fn parse_from(input: &'a str) -> IResult<&'a str, Self> { + fn parse_from(input: &'a str) -> IRes<'a, Self> { let (input, ref_type_str) = take_until(":")(input)?; let (input, _) = char(':')(input)?; @@ -33,11 +83,13 @@ impl<'a> FlakeRef<'a> { } else { FlakeRefType::Gitlab }; - let (input, repo_and_sha) = + let (input, repo_and_sha) = context( + "expected owner/repo/sha", verify(take_while1(|c: char| c != '?' && c != '\n'), |s: &str| { s.matches('/').count() == 2 - }) - .parse(input)?; + }), + ) + .parse(input)?; let (input, _) = opt(|i| { let (i, _) = char('?')(i)?; not_line_ending(i) @@ -55,23 +107,23 @@ impl<'a> FlakeRef<'a> { }, )) } - // TODO for now just parsese "tangled.org" and the rest will error + // TODO for now just parses "tangled.org" and the rest will error "git+https" => { - let (input, _) = tag("//tangled.org/@")(input)?; + let (input, _) = + context("expected tangled.org host", tag("//tangled.org/@")).parse(input)?; let (input, path) = take_while1(|c: char| c != '?' && c != '\n').parse(input)?; let (input, _) = char('?')(input)?; let (input, query_str) = not_line_ending(input)?; let params: Vec<&str> = query_str.split('&').collect(); let commit = params .iter() - .find_map(|p| p.strip_prefix("rev=")) - // TODO fall back to ref? - .or_else(|| params.iter().find_map(|p| p.strip_prefix("ref="))) + .find_map(|p: &&str| p.strip_prefix("rev=")) + .or_else(|| params.iter().find_map(|p: &&str| p.strip_prefix("ref="))) .ok_or_else(|| { - nom::Err::Error(nom::error::Error::new( - query_str, - nom::error::ErrorKind::Tag, - )) + nom::Err::Error(Error { + input: query_str, + context: Some("expected rev= or ref= query param"), + }) })?; Ok(( input, @@ -82,10 +134,12 @@ impl<'a> FlakeRef<'a> { }, )) } - _ => Err(nom::Err::Error(nom::error::Error::new( - input, - nom::error::ErrorKind::Tag, - ))), + _ => Err(nom::Err::Error(Error { + input: ref_type_str, + context: Some( + "unknown flake ref type: supports github, gitlab, git+https://tangled.org/@", + ), + })), } } @@ -109,14 +163,22 @@ pub struct DatedFlakeRef<'a> { } impl<'a> DatedFlakeRef<'a> { - fn parse_from(input: &'a str) -> IResult<&'a str, Self> { + fn parse_from(input: &'a str) -> IRes<'a, Self> { let (input, _) = space0(input)?; - let (input, url) = delimited(tag("'"), take_until("'"), tag("'")).parse(input)?; + let (input, url) = context( + "expected flake url in single quotes", + delimited(tag("'"), take_until("'"), tag("'")), + ) + .parse(input)?; let (input, _) = space1(input)?; - let (input, date) = delimited(tag("("), take_until(")"), tag(")")).parse(input)?; + let (input, date) = context( + "expected date in parentheses", + delimited(tag("("), take_until(")"), tag(")")), + ) + .parse(input)?; let (input, _) = line_ending(input)?; - let (_, flake_ref) = FlakeRef::parse_from(url)?; + let (_, flake_ref) = context("flake ref url", FlakeRef::parse_from).parse(url)?; Ok((input, DatedFlakeRef { flake_ref, date })) } @@ -129,11 +191,11 @@ pub struct UpdateInfo<'a> { } impl<'a> UpdateInfo<'a> { - fn parse_from(input: &'a str) -> IResult<&'a str, Self> { - let (input, from) = DatedFlakeRef::parse_from(input)?; + fn parse_from(input: &'a str) -> IRes<'a, Self> { + let (input, from) = context("'from' flake ref", DatedFlakeRef::parse_from).parse(input)?; let (input, _) = space0(input)?; - let (input, _) = tag("→")(input)?; - let (input, to) = DatedFlakeRef::parse_from(input)?; + let (input, _) = context("expected → arrow", tag("→")).parse(input)?; + let (input, to) = context("'to' flake ref", DatedFlakeRef::parse_from).parse(input)?; Ok((input, UpdateInfo { from, to })) } @@ -162,7 +224,7 @@ pub enum AddInfo<'a> { } impl<'a> AddInfo<'a> { - fn parse_from(input: &'a str) -> IResult<&'a str, Self> { + fn parse_from(input: &'a str) -> IRes<'a, Self> { alt(( |i| { let (i, _) = space0(i)?; @@ -191,11 +253,9 @@ impl<'a> Entry<'a> { pub fn summary(&self) -> String { match self { Entry::Updated(name, info) => { - let url = if let Some(url) = info.url() { - url - } else { - String::from("incompatible url") - }; + let url = info + .url() + .unwrap_or_else(|| String::from("incompatible url")); format!( " - Updated input [`{name}`]({}): [`{}` ➡️ `{}`]({}) ({} to {})", info.from.flake_ref.repo_url(), @@ -205,7 +265,6 @@ impl<'a> Entry<'a> { info.from.date, info.to.date, ) - .to_string() } Entry::Added(info) => match info { AddInfo::Follows(repo) => format!(" - Added input (follows `{}`)", repo), @@ -216,21 +275,19 @@ impl<'a> Entry<'a> { dated_ref.date ), }, - Entry::Removed(name) => { - format!(" - Removed input `{name}`") - } + Entry::Removed(name) => format!(" - Removed input `{name}`"), } } } -pub fn parse_header(input: &str) -> IResult<&str, ()> { +pub fn parse_header(input: &str) -> IRes<'_, ()> { let (input, _) = tag("Flake lock file updates:")(input)?; let (input, _) = line_ending(input)?; let (input, _) = line_ending(input)?; Ok((input, ())) } -fn parse_updated(input: &str) -> IResult<&str, Entry<'_>> { +fn parse_updated(input: &str) -> IRes<'_, Entry<'_>> { let (input, _) = tag("• Updated input '")(input)?; let (input, package) = take_until("':")(input)?; let (input, _) = tag("':")(input)?; @@ -239,7 +296,7 @@ fn parse_updated(input: &str) -> IResult<&str, Entry<'_>> { Ok((input, Entry::Updated(package, info))) } -fn parse_added(input: &str) -> IResult<&str, Entry<'_>> { +fn parse_added(input: &str) -> IRes<'_, Entry<'_>> { let (input, _) = tag("• Added input '")(input)?; let (input, _) = take_until("':")(input)?; let (input, _) = tag("':")(input)?; @@ -248,7 +305,7 @@ fn parse_added(input: &str) -> IResult<&str, Entry<'_>> { Ok((input, Entry::Added(info))) } -fn parse_removed(input: &str) -> IResult<&str, Entry<'_>> { +fn parse_removed(input: &str) -> IRes<'_, Entry<'_>> { let (input, _) = tag("• Removed input '")(input)?; let (input, package) = take_until("'")(input)?; let (input, _) = tag("'")(input)?; @@ -256,7 +313,7 @@ fn parse_removed(input: &str) -> IResult<&str, Entry<'_>> { Ok((input, Entry::Removed(package))) } -pub fn parse_entry(input: &str) -> IResult<&str, Entry<'_>> { +pub fn parse_entry(input: &str) -> IRes<'_, Entry<'_>> { alt((parse_updated, parse_added, parse_removed)).parse(input) } @@ -265,6 +322,32 @@ mod tests { use super::*; use nom::multi::many0; + #[test] + fn test_parse_error_unknown_flake_ref() { + let input = "• Updated input 'foo':\n 'git://xyz.org/bar?rev=abc' (2025-01-01)\n → 'git://xyz.org/bar?rev=def' (2025-01-02)\n"; + match parse_entry(input) { + Err(nom::Err::Error(e)) => { + assert_eq!( + e.context, + Some( + "unknown flake ref type: supports github, gitlab, git+https://tangled.org/@" + ) + ); + assert_eq!(e.input, "git"); + } + other => panic!("expected error, got {other:?}"), + } + } + + #[test] + fn test_parse_error_tangled_wrong_host() { + let input = "• Updated input 'foo':\n 'git+https://tang.org/@foo/bar?rev=abc' (2025-01-01)\n → 'git+https://tangled.org/@foo/bar?rev=def' (2025-01-02)\n"; + match parse_entry(input) { + Err(nom::Err::Error(e)) => assert_eq!(e.context, Some("expected tangled.org host")), + other => panic!("expected error, got {other:?}"), + } + } + #[test] fn test_parse_flake_ref_tangled() { let input = "git+https://tangled.org/@tangled.org/core?ref=refs/heads/master&rev=9afe7ce0460ac5b13ad0079bddf35e92adbc5fcb"; diff --git a/src/main.rs b/src/main.rs index 2293598..105a051 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,18 @@ fn main() -> ExitCode { let remaining = match parse_header(&input) { Ok((remaining, _)) => remaining, + Err(nom::Err::Error(e) | nom::Err::Failure(e)) => { + println!("
Parse errors

"); + println!("\n```"); + println!( + "Failed to parse header ({}): `{}`", + e.context.unwrap_or("unknown"), + e.input.lines().next().unwrap_or("") + ); + println!("```"); + println!("\n

\n"); + return ExitCode::FAILURE; + } Err(e) => { println!("
Parse errors

"); println!("\n```"); @@ -40,7 +52,8 @@ fn main() -> ExitCode { entries.push(entry); current = rest; } - Err(_) => { + Err(nom::Err::Incomplete(_)) => break, + Err(nom::Err::Error(e) | nom::Err::Failure(e)) => { let offset = current.as_ptr() as usize - input.as_ptr() as usize; let line_num = input[..offset].lines().count() + 1; let next = current @@ -55,7 +68,12 @@ fn main() -> ExitCode { Some(rest) => ¤t[..current.len() - rest.len()], None => current, }; - errors.push(format!("line {line_num}:\n{}", bad_chunk.trim())); + let reason = e.context.unwrap_or("unknown"); + let fail_line = e.input.lines().next().unwrap_or(""); + errors.push(format!( + "line {line_num} ({reason}): `{fail_line}`\n{}", + bad_chunk.trim() + )); match next { Some(rest) => current = rest, None => break, From d18d3be5885fa230b14028f785eca91a2491e4ca Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Tue, 12 May 2026 15:25:54 +0200 Subject: [PATCH 3/7] slimmer main and parse error struct --- src/lib.rs | 120 ++++++++++++++++++++++++++++++++++++++++++++-------- src/main.rs | 83 +++++++++--------------------------- 2 files changed, 122 insertions(+), 81 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f8ce7a2..723836c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -156,10 +156,30 @@ impl<'a> FlakeRef<'a> { } } +#[derive(Debug, PartialEq)] +pub struct Date<'a>(&'a str); + +impl<'a> Date<'a> { + fn parse_from(input: &'a str) -> IRes<'a, Self> { + context( + "expected date in parentheses", + delimited(tag("("), take_until(")"), tag(")")), + ) + .map(Date) + .parse(input) + } +} + +impl std::fmt::Display for Date<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} + #[derive(Debug)] pub struct DatedFlakeRef<'a> { flake_ref: FlakeRef<'a>, - date: &'a str, + date: Date<'a>, } impl<'a> DatedFlakeRef<'a> { @@ -171,11 +191,7 @@ impl<'a> DatedFlakeRef<'a> { ) .parse(input)?; let (input, _) = space1(input)?; - let (input, date) = context( - "expected date in parentheses", - delimited(tag("("), take_until(")"), tag(")")), - ) - .parse(input)?; + let (input, date) = Date::parse_from(input)?; let (input, _) = line_ending(input)?; let (_, flake_ref) = context("flake ref url", FlakeRef::parse_from).parse(url)?; @@ -194,7 +210,7 @@ impl<'a> UpdateInfo<'a> { fn parse_from(input: &'a str) -> IRes<'a, Self> { let (input, from) = context("'from' flake ref", DatedFlakeRef::parse_from).parse(input)?; let (input, _) = space0(input)?; - let (input, _) = context("expected → arrow", tag("→")).parse(input)?; + let (input, _) = context("expected arrow", tag("→")).parse(input)?; let (input, to) = context("'to' flake ref", DatedFlakeRef::parse_from).parse(input)?; Ok((input, UpdateInfo { from, to })) @@ -229,7 +245,11 @@ impl<'a> AddInfo<'a> { |i| { let (i, _) = space0(i)?; let (i, _) = tag("follows ")(i)?; - let (i, repo) = delimited(tag("'"), take_until("'"), tag("'")).parse(i)?; + let (i, repo) = context( + "expected repo in single quotes", + delimited(tag("'"), take_until("'"), tag("'")), + ) + .parse(i)?; let (i, _) = line_ending(i)?; Ok((i, AddInfo::Follows(repo))) }, @@ -280,7 +300,73 @@ impl<'a> Entry<'a> { } } -pub fn parse_header(input: &str) -> IRes<'_, ()> { +pub struct ParseFailure<'a> { + pub line_num: usize, + pub context: Option<&'static str>, + pub fail_line: &'a str, + pub bad_chunk: &'a str, +} + +pub struct ParseResult<'a> { + pub entries: Vec>, + pub failures: Vec>, +} + +pub fn parse_commit_message(input: &str) -> Result, Error<'_>> { + let (mut current, _) = parse_header(input).map_err(|e| match e { + nom::Err::Error(e) | nom::Err::Failure(e) => e, + nom::Err::Incomplete(_) => Error { + input, + context: Some("incomplete input"), + }, + })?; + + let mut entries = Vec::new(); + let mut failures = Vec::new(); + + loop { + if current.trim().is_empty() { + break; + } + match parse_entry(current) { + Ok((rest, entry)) => { + entries.push(entry); + current = rest; + } + Err(nom::Err::Incomplete(_)) => break, + Err(nom::Err::Error(e) | nom::Err::Failure(e)) => { + let offset = current.as_ptr() as usize - input.as_ptr() as usize; + let line_num = input[..offset].lines().count() + 1; + let next = current + .split_inclusive('\n') + .skip(1) + .find(|line| line.starts_with('•')) + .map(|line| { + let line_offset = line.as_ptr() as usize - current.as_ptr() as usize; + ¤t[line_offset..] + }); + let bad_chunk = match next { + Some(rest) => ¤t[..current.len() - rest.len()], + None => current, + }; + failures.push(ParseFailure { + line_num, + context: e.context, + fail_line: e.input.lines().next().unwrap_or(""), + bad_chunk: bad_chunk.trim(), + }); + match next { + Some(rest) => current = rest, + None => break, + } + } + } + } + + Ok(ParseResult { entries, failures }) +} + +fn parse_header(input: &str) -> IRes<'_, ()> { let (input, _) = tag("Flake lock file updates:")(input)?; let (input, _) = line_ending(input)?; let (input, _) = line_ending(input)?; @@ -313,14 +399,13 @@ fn parse_removed(input: &str) -> IRes<'_, Entry<'_>> { Ok((input, Entry::Removed(package))) } -pub fn parse_entry(input: &str) -> IRes<'_, Entry<'_>> { +fn parse_entry(input: &str) -> IRes<'_, Entry<'_>> { alt((parse_updated, parse_added, parse_removed)).parse(input) } #[cfg(test)] mod tests { use super::*; - use nom::multi::many0; #[test] fn test_parse_error_unknown_flake_ref() { @@ -423,10 +508,9 @@ mod tests { follows 'nihilistic-nvim/rustacean-nvim/flake-parts' "#; - let remaining = parse_header(input).expect("Failed to parse header").0; - let (_, entries) = many0(parse_entry) - .parse(remaining) - .expect("Failed to parse entries"); + let result = parse_commit_message(input).expect("Failed to parse commit message"); + assert!(result.failures.is_empty()); + let entries = result.entries; assert_eq!(entries.len(), 9); @@ -439,14 +523,14 @@ mod tests { info.from.flake_ref.commit, "bd92e8ee4a6031ca3dd836c91dc41c13fca1e533" ); - assert_eq!(info.from.date, "2025-10-03"); + assert_eq!(info.from.date, Date("2025-10-03")); assert_eq!(info.to.flake_ref.ref_type, FlakeRefType::Github); assert_eq!(info.to.flake_ref.repo, "nix-community/home-manager"); assert_eq!( info.to.flake_ref.commit, "bcccb01d0a353c028cc8cb3254cac7ebae32929e" ); - assert_eq!(info.to.date, "2025-10-10"); + assert_eq!(info.to.date, Date("2025-10-10")); } _ => panic!("Expected Updated entry"), } @@ -459,7 +543,7 @@ mod tests { info.flake_ref.commit, "11707dc2f618dd54ca8739b309ec4fc024de578b" ); - assert_eq!(info.date, "2024-11-13"); + assert_eq!(info.date, Date("2024-11-13")); } _ => panic!("Expected Added entry with New"), } diff --git a/src/main.rs b/src/main.rs index 105a051..c283644 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use flakers::{parse_entry, parse_header}; +use flakers::{Entry, parse_commit_message}; use std::io::{self, Read}; use std::process::ExitCode; @@ -15,9 +15,9 @@ fn main() -> ExitCode { println!("```"); println!("\n

\n"); - let remaining = match parse_header(&input) { - Ok((remaining, _)) => remaining, - Err(nom::Err::Error(e) | nom::Err::Failure(e)) => { + let result = match parse_commit_message(&input) { + Ok(result) => result, + Err(e) => { println!("
Parse errors

"); println!("\n```"); println!( @@ -29,79 +29,36 @@ fn main() -> ExitCode { println!("\n

\n"); return ExitCode::FAILURE; } - Err(e) => { - println!("
Parse errors

"); - println!("\n```"); - println!("Failed to parse header: {e}"); - println!("```"); - println!("\n

\n"); - return ExitCode::FAILURE; - } }; - let mut current = remaining; - let mut entries = Vec::new(); - let mut errors: Vec = Vec::new(); - - loop { - if current.trim().is_empty() { - break; - } - match parse_entry(current) { - Ok((rest, entry)) => { - entries.push(entry); - current = rest; - } - Err(nom::Err::Incomplete(_)) => break, - Err(nom::Err::Error(e) | nom::Err::Failure(e)) => { - let offset = current.as_ptr() as usize - input.as_ptr() as usize; - let line_num = input[..offset].lines().count() + 1; - let next = current - .split_inclusive('\n') - .skip(1) - .find(|line| line.starts_with('•')) - .map(|line| { - let offset = line.as_ptr() as usize - current.as_ptr() as usize; - ¤t[offset..] - }); - let bad_chunk = match next { - Some(rest) => ¤t[..current.len() - rest.len()], - None => current, - }; - let reason = e.context.unwrap_or("unknown"); - let fail_line = e.input.lines().next().unwrap_or(""); - errors.push(format!( - "line {line_num} ({reason}): `{fail_line}`\n{}", - bad_chunk.trim() - )); - match next { - Some(rest) => current = rest, - None => break, - } - } - } - } - - if !errors.is_empty() { + if !result.failures.is_empty() { println!("
Parse errors

"); println!("\n```"); - for error in &errors { - println!("{error}"); + for failure in &result.failures { + println!( + "line {} ({}): `{}`\n{}", + failure.line_num, + failure.context.unwrap_or("unknown"), + failure.fail_line, + failure.bad_chunk + ); } println!("```"); println!("\n

\n"); } - entries + result + .entries .iter() - .filter(|e| matches!(e, flakers::Entry::Added(_))) + .filter(|e| matches!(e, Entry::Added(_))) .for_each(|e| println!("{}", e.summary())); - entries + result + .entries .iter() - .filter(|e| matches!(e, flakers::Entry::Updated(_, _))) + .filter(|e| matches!(e, Entry::Updated(_, _))) .for_each(|e| println!("{}", e.summary())); - if errors.is_empty() { + if result.failures.is_empty() { ExitCode::SUCCESS } else { ExitCode::FAILURE From 490adc6333d58ad179f92ce56645a79bcdc2fd82 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Tue, 12 May 2026 15:47:34 +0200 Subject: [PATCH 4/7] unify parse_entry --- src/lib.rs | 52 +++++++++++++++++++++++++--------------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 723836c..a9542d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -373,34 +373,32 @@ fn parse_header(input: &str) -> IRes<'_, ()> { Ok((input, ())) } -fn parse_updated(input: &str) -> IRes<'_, Entry<'_>> { - let (input, _) = tag("• Updated input '")(input)?; - let (input, package) = take_until("':")(input)?; - let (input, _) = tag("':")(input)?; - let (input, _) = line_ending(input)?; - let (input, info) = UpdateInfo::parse_from.parse(input)?; - Ok((input, Entry::Updated(package, info))) -} - -fn parse_added(input: &str) -> IRes<'_, Entry<'_>> { - let (input, _) = tag("• Added input '")(input)?; - let (input, _) = take_until("':")(input)?; - let (input, _) = tag("':")(input)?; - let (input, _) = line_ending(input)?; - let (input, info) = AddInfo::parse_from.parse(input)?; - Ok((input, Entry::Added(info))) -} - -fn parse_removed(input: &str) -> IRes<'_, Entry<'_>> { - let (input, _) = tag("• Removed input '")(input)?; - let (input, package) = take_until("'")(input)?; - let (input, _) = tag("'")(input)?; - let (input, _) = line_ending(input)?; - Ok((input, Entry::Removed(package))) -} - fn parse_entry(input: &str) -> IRes<'_, Entry<'_>> { - alt((parse_updated, parse_added, parse_removed)).parse(input) + let (input, _) = tag("• ")(input)?; + let (input, verb) = alt((tag("Updated"), tag("Added"), tag("Removed"))).parse(input)?; + let (input, _) = tag(" input '")(input)?; + let (input, name) = take_until("'")(input)?; + let (input, _) = char('\'')(input)?; + + match verb { + "Updated" => { + let (input, _) = tag(":")(input)?; + let (input, _) = line_ending(input)?; + let (input, info) = UpdateInfo::parse_from.parse(input)?; + Ok((input, Entry::Updated(name, info))) + } + "Added" => { + let (input, _) = tag(":")(input)?; + let (input, _) = line_ending(input)?; + let (input, info) = AddInfo::parse_from.parse(input)?; + Ok((input, Entry::Added(info))) + } + "Removed" => { + let (input, _) = line_ending(input)?; + Ok((input, Entry::Removed(name))) + } + _ => unreachable!(), + } } #[cfg(test)] From badc14afa59dace3929f304ae0bff9f0db55c8d5 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Tue, 12 May 2026 16:03:23 +0200 Subject: [PATCH 5/7] pub for downstream and render_entry --- src/lib.rs | 76 ++++++++++++++++++++++++++--------------------------- src/main.rs | 6 ++--- 2 files changed, 40 insertions(+), 42 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a9542d0..75c9ee9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,17 +58,17 @@ impl<'a, E> nom::error::FromExternalError<&'a str, E> for Error<'a> { } #[derive(Debug, PartialEq)] -enum FlakeRefType { +pub enum FlakeRefType { Github, Gitlab, Tangled, } #[derive(Debug, PartialEq)] -struct FlakeRef<'a> { - ref_type: FlakeRefType, - repo: &'a str, - commit: &'a str, +pub struct FlakeRef<'a> { + pub ref_type: FlakeRefType, + pub repo: &'a str, + pub commit: &'a str, } impl<'a> FlakeRef<'a> { @@ -143,7 +143,7 @@ impl<'a> FlakeRef<'a> { } } - fn repo_url(&self) -> String { + pub fn repo_url(&self) -> String { match &self.ref_type { FlakeRefType::Github => format!("https://github.com/{}", self.repo), FlakeRefType::Gitlab => format!("https://gitlab.com/{}", self.repo), @@ -151,7 +151,7 @@ impl<'a> FlakeRef<'a> { } } - fn sha(&self) -> String { + pub fn sha(&self) -> String { self.commit[..8.min(self.commit.len())].to_string() } } @@ -178,8 +178,8 @@ impl std::fmt::Display for Date<'_> { #[derive(Debug)] pub struct DatedFlakeRef<'a> { - flake_ref: FlakeRef<'a>, - date: Date<'a>, + pub flake_ref: FlakeRef<'a>, + pub date: Date<'a>, } impl<'a> DatedFlakeRef<'a> { @@ -202,8 +202,8 @@ impl<'a> DatedFlakeRef<'a> { #[derive(Debug)] pub struct UpdateInfo<'a> { - from: DatedFlakeRef<'a>, - to: DatedFlakeRef<'a>, + pub from: DatedFlakeRef<'a>, + pub to: DatedFlakeRef<'a>, } impl<'a> UpdateInfo<'a> { @@ -216,7 +216,7 @@ impl<'a> UpdateInfo<'a> { Ok((input, UpdateInfo { from, to })) } - fn url(&self) -> Option { + pub fn url(&self) -> Option { let from = &self.from.flake_ref; let to = &self.to.flake_ref; @@ -269,34 +269,32 @@ pub enum Entry<'a> { Removed(&'a str), } -impl<'a> Entry<'a> { - pub fn summary(&self) -> String { - match self { - Entry::Updated(name, info) => { - let url = info - .url() - .unwrap_or_else(|| String::from("incompatible url")); - format!( - " - Updated input [`{name}`]({}): [`{}` ➡️ `{}`]({}) ({} to {})", - info.from.flake_ref.repo_url(), - info.from.flake_ref.sha(), - info.to.flake_ref.sha(), - url, - info.from.date, - info.to.date, - ) - } - Entry::Added(info) => match info { - AddInfo::Follows(repo) => format!(" - Added input (follows `{}`)", repo), - AddInfo::New(dated_ref) => format!( - " - Added input [`{}`]({}) ({})", - dated_ref.flake_ref.sha(), - dated_ref.flake_ref.repo_url(), - dated_ref.date - ), - }, - Entry::Removed(name) => format!(" - Removed input `{name}`"), +pub fn render_entry(entry: &Entry) -> String { + match entry { + Entry::Updated(name, info) => { + let url = info + .url() + .unwrap_or_else(|| String::from("incompatible url")); + format!( + " - Updated input [`{name}`]({}): [`{}` ➡️ `{}`]({}) ({} to {})", + info.from.flake_ref.repo_url(), + info.from.flake_ref.sha(), + info.to.flake_ref.sha(), + url, + info.from.date, + info.to.date, + ) } + Entry::Added(info) => match info { + AddInfo::Follows(repo) => format!(" - Added input (follows `{}`)", repo), + AddInfo::New(dated_ref) => format!( + " - Added input [`{}`]({}) ({})", + dated_ref.flake_ref.sha(), + dated_ref.flake_ref.repo_url(), + dated_ref.date + ), + }, + Entry::Removed(name) => format!(" - Removed input `{name}`"), } } diff --git a/src/main.rs b/src/main.rs index c283644..c3c0ba2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use flakers::{Entry, parse_commit_message}; +use flakers::{Entry, parse_commit_message, render_entry}; use std::io::{self, Read}; use std::process::ExitCode; @@ -51,12 +51,12 @@ fn main() -> ExitCode { .entries .iter() .filter(|e| matches!(e, Entry::Added(_))) - .for_each(|e| println!("{}", e.summary())); + .for_each(|e| println!("{}", render_entry(e))); result .entries .iter() .filter(|e| matches!(e, Entry::Updated(_, _))) - .for_each(|e| println!("{}", e.summary())); + .for_each(|e| println!("{}", render_entry(e))); if result.failures.is_empty() { ExitCode::SUCCESS From b48f9ccb7eaddbe3e8ee89f0f8787898475d61c1 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Tue, 12 May 2026 16:06:34 +0200 Subject: [PATCH 6/7] more tests --- src/lib.rs | 154 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 75c9ee9..db7a4eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -298,6 +298,7 @@ pub fn render_entry(entry: &Entry) -> String { } } +#[derive(Debug)] pub struct ParseFailure<'a> { pub line_num: usize, pub context: Option<&'static str>, @@ -305,6 +306,7 @@ pub struct ParseFailure<'a> { pub bad_chunk: &'a str, } +#[derive(Debug)] pub struct ParseResult<'a> { pub entries: Vec>, pub failures: Vec>, @@ -558,4 +560,156 @@ mod tests { _ => panic!("Expected Added entry with Follows"), } } + + #[test] + fn test_parse_commit_message_header_failure() { + let input = "Not a flake lock file\n\n• Updated input 'foo':\n"; + let err = parse_commit_message(input).expect_err("expected header parse failure"); + assert_eq!( + err.input, + "Not a flake lock file\n\n• Updated input 'foo':\n" + ); + } + + #[test] + fn test_parse_commit_message_partial_failure() { + let input = "Flake lock file updates:\n\n• Updated input 'foo':\n 'git://xyz.org/bar?rev=abc' (2025-01-01)\n → 'git://xyz.org/bar?rev=def' (2025-01-02)\n• Removed input 'bar'\n"; + + let result = parse_commit_message(input).expect("should parse despite entry error"); + + assert_eq!(result.entries.len(), 1); + assert_eq!(result.failures.len(), 1); + + match &result.entries[0] { + Entry::Removed(name) => assert_eq!(*name, "bar"), + _ => panic!("expected Removed entry"), + } + + let failure = &result.failures[0]; + assert_eq!(failure.line_num, 3); + assert_eq!( + failure.context, + Some("unknown flake ref type: supports github, gitlab, git+https://tangled.org/@") + ); + assert_eq!(failure.fail_line, "git"); + assert_eq!( + failure.bad_chunk, + "• Updated input 'foo':\n 'git://xyz.org/bar?rev=abc' (2025-01-01)\n → 'git://xyz.org/bar?rev=def' (2025-01-02)" + ); + } + + #[test] + fn test_parse_commit_message_all_failures() { + let input = "Flake lock file updates:\n\n• Updated input 'foo':\n 'git://xyz.org/bar?rev=abc' (2025-01-01)\n → 'git://xyz.org/bar?rev=def' (2025-01-02)\n"; + + let result = parse_commit_message(input).expect("should return Ok with failures"); + assert!(result.entries.is_empty()); + assert_eq!(result.failures.len(), 1); + } + + #[test] + fn test_render_entry_updated_same_repo() { + let sha_a = "a".repeat(40); + let sha_b = "b".repeat(40); + let input = format!( + "• Updated input 'nixpkgs':\n 'github:nixos/nixpkgs/{sha_a}' (2025-01-01)\n → 'github:nixos/nixpkgs/{sha_b}' (2025-01-02)\n" + ); + let (_, entry) = parse_entry(&input).expect("valid entry"); + assert_eq!( + render_entry(&entry), + format!( + " - Updated input [`nixpkgs`](https://github.com/nixos/nixpkgs): \ + [`aaaaaaaa` ➡️ `bbbbbbbb`](https://github.com/nixos/nixpkgs/compare/{sha_a}...{sha_b}) \ + (2025-01-01 to 2025-01-02)" + ) + ); + } + + #[test] + fn test_render_entry_updated_cross_repo() { + let sha_a = "a".repeat(40); + let sha_b = "b".repeat(40); + let input = format!( + "• Updated input 'foo':\n 'github:owner/repo-a/{sha_a}' (2025-01-01)\n → 'github:owner/repo-b/{sha_b}' (2025-01-02)\n" + ); + let (_, entry) = parse_entry(&input).expect("valid entry"); + assert!(render_entry(&entry).contains("incompatible url")); + } + + #[test] + fn test_render_entry_added_new() { + let sha = "a".repeat(40); + let input = format!( + "• Added input 'flake-utils':\n 'github:numtide/flake-utils/{sha}' (2025-01-01)\n" + ); + let (_, entry) = parse_entry(&input).expect("valid entry"); + assert_eq!( + render_entry(&entry), + " - Added input [`aaaaaaaa`](https://github.com/numtide/flake-utils) (2025-01-01)" + ); + } + + #[test] + fn test_render_entry_added_follows() { + let input = "• Added input 'foo/flake-parts':\n follows 'foo/flake-utils'\n"; + let (_, entry) = parse_entry(input).expect("valid entry"); + assert_eq!( + render_entry(&entry), + " - Added input (follows `foo/flake-utils`)" + ); + } + + #[test] + fn test_render_entry_removed() { + let input = "• Removed input 'old-dep'\n"; + let (_, entry) = parse_entry(input).expect("valid entry"); + assert_eq!(render_entry(&entry), " - Removed input `old-dep`"); + } + + #[test] + fn test_update_info_url_same_repo() { + let sha_a = "a".repeat(40); + let sha_b = "b".repeat(40); + let input = format!( + "• Updated input 'foo':\n 'github:owner/repo/{sha_a}' (2025-01-01)\n → 'github:owner/repo/{sha_b}' (2025-01-02)\n" + ); + let (_, entry) = parse_entry(&input).expect("valid entry"); + match entry { + Entry::Updated(_, info) => assert_eq!( + info.url(), + Some(format!( + "https://github.com/owner/repo/compare/{sha_a}...{sha_b}" + )) + ), + _ => panic!("expected Updated"), + } + } + + #[test] + fn test_update_info_url_different_repo() { + let sha_a = "a".repeat(40); + let sha_b = "b".repeat(40); + let input = format!( + "• Updated input 'foo':\n 'github:owner/repo-a/{sha_a}' (2025-01-01)\n → 'github:owner/repo-b/{sha_b}' (2025-01-02)\n" + ); + let (_, entry) = parse_entry(&input).expect("valid entry"); + match entry { + Entry::Updated(_, info) => assert_eq!(info.url(), None), + _ => panic!("expected Updated"), + } + } + + #[test] + fn test_update_info_url_different_ref_type() { + let sha_a = "a".repeat(40); + let sha_b = "b".repeat(40); + let input = format!( + "• Updated input 'foo':\n 'github:owner/repo/{sha_a}' (2025-01-01)\n → 'gitlab:owner/repo/{sha_b}' (2025-01-02)\n" + ); + let (_, entry) = parse_entry(&input).expect("valid entry"); + match entry { + Entry::Updated(_, info) => assert_eq!(info.url(), None), + _ => panic!("expected Updated"), + } + } } From a2e56b9ae126585a295d74067a36e6080d5ce925 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 13 May 2026 08:33:59 +0200 Subject: [PATCH 7/7] cleanup --- src/lib.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index db7a4eb..c74ad0c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,20 @@ use nom::{ type IRes<'a, T> = IResult<&'a str, T, Error<'a>>; +#[derive(Debug)] +pub struct ParseFailure<'a> { + pub line_num: usize, + pub context: Option<&'static str>, + pub fail_line: &'a str, + pub bad_chunk: &'a str, +} + +#[derive(Debug)] +pub struct ParseResult<'a> { + pub entries: Vec>, + pub failures: Vec>, +} + /// Error type that captures the input position and the innermost context label. #[derive(Debug)] pub struct Error<'a> { @@ -298,20 +312,6 @@ pub fn render_entry(entry: &Entry) -> String { } } -#[derive(Debug)] -pub struct ParseFailure<'a> { - pub line_num: usize, - pub context: Option<&'static str>, - pub fail_line: &'a str, - pub bad_chunk: &'a str, -} - -#[derive(Debug)] -pub struct ParseResult<'a> { - pub entries: Vec>, - pub failures: Vec>, -} - pub fn parse_commit_message(input: &str) -> Result, Error<'_>> { let (mut current, _) = parse_header(input).map_err(|e| match e { nom::Err::Error(e) | nom::Err::Failure(e) => e,