Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,13 @@ fspec is intentionally staged. Not all features need to exist at once.
- [x] Improve the parsing grammar design to require/allow the `:` sigil for limiters.
- [x] Introduce a command line tool wrapper crate.
- [x] Make the basic rule engine usable in real world cases.
- [ ] Command line tool output switches and JSON report output.
- [x] Command line tool output switches and JSON report output.

## Level 2 — Diagnostics and Expansion

- [ ] Named path aliases (reduce repetition; improve readability for hierarchical specs) [see proposal.](./docs/proposals/named-path-aliases.md)
- [ ] Improve command line switches
- [ ] Improve logging and verbosity
- [ ] explain which rule matched
- [ ] ambiguity detection and warnings
- [ ] warn on re-allowed ignored paths
Expand Down
32 changes: 20 additions & 12 deletions crates/fspec-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,33 +17,41 @@ pub use spec::{DirType, FSEntry, FSPattern, FileType, MatchSettings, Rule, RuleK
pub use walk::{WalkCtx, WalkOutput};

pub fn check_tree(root: &Path, settings: &MatchSettings) -> Result<Report, Error> {
check_tree_with_spec(root, None, settings)
}

/// If `spec_path` is `Some`, that path is used. Otherwise defaults to `{root}/.fspec`.
pub fn check_tree_with_spec(
root: &Path,
spec_path: Option<&Path>,
settings: &MatchSettings,
) -> Result<Report, Error> {
// --- parse .fspec ---
let fspec_path: PathBuf = root.join(".fspec");
let fspec_path: PathBuf = match spec_path {
Some(p) => p.to_path_buf(),
None => root.join(".fspec"),
};

if !fspec_path.exists() {
return Err(Error::Semantic {
msg: format!(".fspec not found at {}", fspec_path.display()),
});
}

// Optional but usually helpful: fail early if it's not a file.
if !fspec_path.is_file() {
return Err(Error::Semantic {
msg: format!("spec path is not a file: {}", fspec_path.display()),
});
}

let contents = fs::read_to_string(&fspec_path).map_err(|e| Error::Io {
path: fspec_path.clone(),
source: e,
})?;

// TODO: Verbose/debug mode
//println!("{:#?}", contents);

let spec_rules = parse_fspec(&contents, settings)?;

// TODO: verbose/debug mode
//println!("{:#?}", spec_rules);

let walk_output = walk::walk_tree(root, &spec_rules)?;

// TODO: verbose/debug mode
//println!("{:#?}", walk_output);

let report = Report::from_walk_output(&walk_output);

Ok(report)
Expand Down
2 changes: 2 additions & 0 deletions crates/fspec/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ edition = "2024"
[dependencies]
clap = { version = "4", features = ["derive"] }
fspec-core = { path = "../fspec-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
10 changes: 10 additions & 0 deletions crates/fspec/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ pub struct Cli {
#[arg(long)]
pub spec: Option<PathBuf>,

/// Output format
#[arg(long, value_enum, default_value_t = OutputFormat::Human)]
pub format: OutputFormat,

/// Leaf matching mode
#[arg(long, value_enum, default_value_t = LeafMode::Loose)]
pub leaf: LeafMode,
Expand All @@ -34,6 +38,12 @@ pub struct Cli {
pub verbosity: u8,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum OutputFormat {
Human,
Json,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum LeafMode {
Strict,
Expand Down
43 changes: 13 additions & 30 deletions crates/fspec/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
mod args;
mod render;

use crate::args::{Cli, LeafMode, SeverityArg};
use crate::args::{Cli, LeafMode, OutputFormat, SeverityArg};
use clap::Parser;
use fspec_core::{MatchSettings, Severity, check_tree};
use fspec_core::{MatchSettings, Severity, check_tree, check_tree_with_spec};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

Expand All @@ -12,15 +12,6 @@ fn main() -> ExitCode {

let root: PathBuf = resolve_root(&cli);

// NOTE: fspec-core currently hardcodes <root>/.fspec inside check_tree().
// Until core supports a custom spec path, we error out if --spec is used.
if cli.spec.is_some() {
eprintln!(
"error: --spec is not implemented yet (fspec-core currently only reads <root>/.fspec)"
);
return ExitCode::from(2);
}

let mut settings = MatchSettings::default();

// These fields are described in your CLI design doc.
Expand All @@ -32,27 +23,19 @@ fn main() -> ExitCode {
SeverityArg::Error => Severity::Error,
};

// TODO: Update check_tree to support running an a different directory than
// the .fspec resides in. like:
// let report = if let Some(spec) = &cli.spec {
// check_tree_with_spec(&root, spec, &settings)
// } else {
// check_tree(&root, &settings)
// };

let report = match check_tree(&root, &settings) {
Ok(r) => r,
Err(e) => {
eprintln!("{e}");
return ExitCode::from(2);
}
};
let report = (if let Some(spec) = cli.spec.as_deref() {
check_tree_with_spec(&root, Some(spec), &settings)
} else {
check_tree(&root, &settings)
})
.unwrap_or_else(|e| {
eprintln!("{e}");
std::process::exit(2);
});

let out = render::render_human(&report, &settings, cli.verbosity, cli.quiet);
let out = render::render(&report, &settings, cli.format, cli.verbosity, cli.quiet);

// For now always stdout (per your “ok if incomplete” note).
// --output/--format can land tomorrow.
print!("{out}");
println!("{}", out);

// Current “finding” heuristic: any unaccounted path => fail.
// (In the future: incorporate per-item severity + threshold logic.)
Expand Down
83 changes: 83 additions & 0 deletions crates/fspec/src/render.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,74 @@
use crate::args::OutputFormat;
use fspec_core::{MatchSettings, Report, Severity};
use serde::Serialize;

const SCHEMA_VERSION: &str = "fspec.report.v1";
const TOOL_VERSION: &str = env!("CARGO_PKG_VERSION");

// Until the report schema stabilizes, we probably don't want to directly deserialize the
// struct via serde. so we use a helper.
// This
#[derive(Serialize)]
struct JsonOut<'a> {
schema_version: &'static str,
tool_version: &'static str,
ok: bool,
unaccounted: Vec<&'a str>,
diagnostics: Vec<JsonDiag<'a>>,
summary: JsonSummary,
}

#[derive(Serialize)]
struct JsonDiag<'a> {
code: &'a str,
severity: String,
path: &'a str,
message: &'a str,
rule_lines: &'a [usize],
}

#[derive(Serialize)]
struct JsonSummary {
unaccounted_count: usize,
// you can add more later without breaking humans
}

fn severity_to_string(sev: Severity) -> String {
match sev {
Severity::Info => "info",
Severity::Warning => "warning",
Severity::Error => "error",
}
.to_string()
}

pub fn render_json(report: &Report, settings: &MatchSettings) -> String {
let un = report.unaccounted_paths();
let diags = report.diagnostics();

let out = JsonOut {
schema_version: SCHEMA_VERSION,
tool_version: TOOL_VERSION,
ok: un.is_empty(),
unaccounted: un.clone(),
diagnostics: diags
.iter()
.map(|d| JsonDiag {
code: d.code,
severity: severity_to_string(d.severity),
path: d.path.as_str(),
message: d.message.as_str(),
rule_lines: &d.rule_lines,
})
.collect(),
summary: JsonSummary {
unaccounted_count: un.len(),
},
};

// if this fails, it’s a programmer error; still return something sane
serde_json::to_string_pretty(&out).unwrap_or_else(|_| "{\"ok\":false}".to_string())
}

pub fn render_human(
report: &Report,
Expand Down Expand Up @@ -51,3 +121,16 @@ pub fn render_human(

out
}

pub fn render(
report: &Report,
settings: &MatchSettings,
format: OutputFormat,
verbosity: u8,
quiet: bool,
) -> String {
match format {
OutputFormat::Human => render_human(report, settings, verbosity, quiet),
OutputFormat::Json => render_json(report, settings),
}
}