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
28 changes: 6 additions & 22 deletions .nudge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ rules:
file: "**/*.rs"
content:
- kind: Regex
pattern: "(?m)^[ \\t]*//"
pattern: "(?m)^[ \\t]*//([^/!]|$)"
- hook: PreToolUse
tool: Edit
file: "**/*.rs"
new_content:
- kind: Regex
pattern: "(?m)^[ \\t]*//"
pattern: "(?m)^[ \\t]*//([^/!]|$)"

- name: no-inline-imports
description: Move imports to the top of the file
Expand Down Expand Up @@ -175,26 +175,10 @@ rules:
pattern: '(?m)^\s*//\s*TODO:\s*(?P<todo>.+)$'
suggestion: "// TODO(ISS-XX): {{ $todo }}"

- name: missing-error-context
description: Add context to error propagation for better debugging
message: |
Error propagated without context. Use .context("what failed")?
This preserves error chain. Fix and retry.
on:
- hook: PreToolUse
tool: Write
file: "**/*.rs"
content:
- kind: Regex
pattern: '[^)]\?\s*;'
suggestion: '.context("describe what failed")?;'
- hook: PreToolUse
tool: Edit
file: "**/*.rs"
new_content:
- kind: Regex
pattern: '[^)]\?\s*;'
suggestion: '.context("describe what failed")?;'
# Disabled: this crate uses a custom thiserror `FossaError`/`Result` with `From`
# conversions and no `.context()` method (anyhow/eyre idiom). Plain `?` propagation
# is the established convention throughout the codebase.
# - name: missing-error-context

- name: prefer-newtypes-for-ids
description: Use newtypes for identifiers instead of bare String
Expand Down
128 changes: 127 additions & 1 deletion src/bin/fossapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use clap::Parser;
use fossapi::cli::{Cli, Command, Entity, GetCommand, ListCommand};
use fossapi::{
get_dependencies, FossaClient, Get, Issue, List, Page, PrettyPrint, Project,
ProjectUpdateParams, Revision, Update,
ProjectUpdateParams, Revision, Snippet, SnippetListQuery, SnippetLocation, SnippetPath, Update,
};
use serde::Serialize;
use std::process::ExitCode;
Expand Down Expand Up @@ -67,6 +67,18 @@ async fn handle_get(
let issue = Issue::get(client, id).await?;
output_single(&issue, json)?;
}
GetCommand::Snippet { revision, snippet } => {
let details = fossapi::get_snippet_details(client, &revision, &snippet).await?;
output_single(&details, json)?;
}
GetCommand::SnippetMatch {
revision,
snippet,
path,
} => {
let details = fossapi::get_snippet_match(client, &revision, &snippet, &path).await?;
output_single(&details, json)?;
}
}
Ok(())
}
Expand Down Expand Up @@ -117,6 +129,54 @@ async fn handle_list(
}
let _ = (page, count);
}
ListCommand::Snippets {
revision,
path,
page,
count,
} => {
let query = SnippetListQuery {
path,
..Default::default()
};
let page = page.unwrap_or(1);
let count = count.unwrap_or(20);
let snippets = fossapi::get_snippets_page(client, &revision, query, page, count).await?;
output_page(&snippets, json, |s| SnippetRow::from(s))?;
}
ListCommand::SnippetLocations {
revision,
path,
with_lines,
} => {
let query = SnippetListQuery {
path,
..Default::default()
};
let locations =
fossapi::get_snippet_locations(client, &revision, query, with_lines).await?;
if json {
println!("{}", serde_json::to_string_pretty(&locations)?);
} else {
let rows: Vec<SnippetLocationRow> =
locations.iter().map(SnippetLocationRow::from).collect();
println!("{}", Table::new(rows));
println!("\n{} match location(s)", locations.len());
}
}
ListCommand::SnippetPaths { revision, path } => {
let query = SnippetListQuery {
path,
..Default::default()
};
let paths = fossapi::get_snippet_paths(client, &revision, query).await?;
if json {
println!("{}", serde_json::to_string_pretty(&paths)?);
} else {
let rows: Vec<SnippetPathRow> = paths.iter().map(SnippetPathRow::from).collect();
println!("{}", Table::new(rows));
}
}
}
Ok(())
}
Expand Down Expand Up @@ -292,3 +352,69 @@ impl From<&fossapi::Revision> for RevisionRow {
}
}
}

#[derive(Tabled)]
struct SnippetRow {
id: String,
package: String,
version: String,
#[tabled(rename = "match")]
match_pct: String,
files: u32,
}

impl From<&Snippet> for SnippetRow {
fn from(s: &Snippet) -> Self {
Self {
id: s.id.clone(),
package: s.package.clone(),
version: s.version.clone(),
match_pct: format!("{:.0}%", s.highest_match_percentage * 100.0),
files: s.match_count,
}
}
}

#[derive(Tabled)]
struct SnippetLocationRow {
file: String,
lines: String,
package: String,
#[tabled(rename = "match")]
match_pct: String,
snippet: String,
}

impl From<&SnippetLocation> for SnippetLocationRow {
fn from(l: &SnippetLocation) -> Self {
let lines = match (l.line_start, l.line_end) {
(Some(lo), Some(hi)) => format!("{lo}-{hi}"),
_ => "-".to_string(),
};
Self {
file: l.path.clone(),
lines,
package: format!("{} {}", l.package, l.version),
match_pct: format!("{:.0}%", l.match_percentage * 100.0),
snippet: l.snippet_id.clone(),
}
}
}

#[derive(Tabled)]
struct SnippetPathRow {
#[tabled(rename = "type")]
path_type: String,
path: String,
count: u32,
}

impl From<&SnippetPath> for SnippetPathRow {
fn from(p: &SnippetPath) -> Self {
Self {
path_type: p.path_type.clone(),
path: p.path.clone(),
count: p.count,
}
}
}
52 changes: 52 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,25 @@ pub enum GetCommand {
/// The issue ID.
id: u64,
},
#[command(
about = "Get a snippet's details, including its matched first-party files",
alias = "snippets"
)]
Snippet {
#[arg(help = "The revision locator (e.g. custom+org/repo$ref)")]
revision: String,
#[arg(help = "The snippet ID")]
snippet: String,
},
#[command(about = "Show the side-by-side match details for a snippet at a first-party path")]
SnippetMatch {
#[arg(help = "The revision locator (e.g. custom+org/repo$ref)")]
revision: String,
#[arg(help = "The snippet ID")]
snippet: String,
#[arg(help = "The first-party file path where the snippet matched")]
path: String,
},
}

/// Subcommands for the `list` command with type-safe argument parsing.
Expand Down Expand Up @@ -133,6 +152,39 @@ pub enum ListCommand {
#[arg(long)]
count: Option<u32>,
},
#[command(
about = "List snippets (matched OSS packages) in a revision",
alias = "snippet"
)]
Snippets {
#[arg(help = "The revision locator (e.g. custom+org/repo$ref)")]
revision: String,
#[arg(long, help = "Filter by file/directory path (defaults to /)")]
path: Option<String>,
#[arg(long)]
page: Option<u32>,
#[arg(long)]
count: Option<u32>,
},
#[command(about = "List every snippet match location (first-party file -> matched package)")]
SnippetLocations {
#[arg(help = "The revision locator (e.g. custom+org/repo$ref)")]
revision: String,
#[arg(long, help = "Filter by file/directory path (defaults to /)")]
path: Option<String>,
#[arg(
long,
help = "Resolve the first-party line range for each match (extra API calls)"
)]
with_lines: bool,
},
#[command(about = "List the file/directory tree where snippets were detected")]
SnippetPaths {
#[arg(help = "The revision locator (e.g. custom+org/repo$ref)")]
revision: String,
#[arg(long, help = "File/directory path to drill into (defaults to /)")]
path: Option<String>,
},
}

/// Entity types that can be operated on.
Expand Down
16 changes: 16 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,25 @@ pub use models::{
IssueRemediation,
IssueSource,
IssueStatuses,
// Snippet types
CodeLine,
Snippet,
SnippetIssueCounts,
SnippetKind,
SnippetLicense,
SnippetListQuery,
SnippetLocation,
SnippetMatch,
SnippetMatchDetails,
SnippetPath,
SnippetQuery,
};

// Re-export convenience functions
pub use models::{get_dependencies, get_dependencies_page};
pub use models::{get_issues, get_issues_page, get_project_issues};
pub use models::{get_revision, get_revisions, get_revisions_page};
pub use models::{
get_snippet_details, get_snippet_locations, get_snippet_match, get_snippet_paths,
get_snippets, get_snippets_page,
};
44 changes: 44 additions & 0 deletions src/mcp/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub enum EntityType {
Issue,
/// Package dependency.
Dependency,
/// Snippet match (third-party code matched against first-party code).
Snippet,
}

/// Parameters for the `get` MCP tool.
Expand Down Expand Up @@ -48,6 +50,12 @@ pub struct ListParams {
/// Issue category filter (required for Issue entity: vulnerability, licensing, quality).
#[serde(default)]
pub category: Option<IssueCategory>,
/// File/directory path filter (Snippet entity; defaults to the repository root).
#[serde(default)]
pub path: Option<String>,
/// Resolve the first-party line range for each match (Snippet entity; extra API calls).
#[serde(default)]
pub with_lines: Option<bool>,
}

/// Parameters for the `update` MCP tool.
Expand All @@ -71,6 +79,17 @@ pub struct UpdateParams {
pub public: Option<bool>,
}

/// Parameters for the `snippet_match` MCP tool (the snippet drill-in).
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct SnippetMatchParams {
/// The revision locator.
pub revision: String,
/// The snippet ID.
pub snippet: String,
/// The first-party file path where the snippet matched.
pub path: String,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -177,4 +196,29 @@ mod tests {
let json = serde_json::to_string(&schema).unwrap();
assert!(json.contains("category"));
}

#[test]
fn entity_type_includes_snippet() {
let schema = schemars::schema_for!(EntityType);
let json = serde_json::to_string(&schema).unwrap();
assert!(json.contains("snippet"));
}

#[test]
fn list_params_deserializes_snippet_fields() {
let json = r#"{"entity": "snippet", "parent": "custom+org/repo$main", "path": "/src", "with_lines": true}"#;
let params: ListParams = serde_json::from_str(json).unwrap();
assert!(matches!(params.entity, EntityType::Snippet));
assert_eq!(params.path.as_deref(), Some("/src"));
assert_eq!(params.with_lines, Some(true));
}

#[test]
fn snippet_match_params_deserializes() {
let json = r#"{"revision": "custom+org/repo$main", "snippet": "1295019", "path": "/src/a.rs"}"#;
let params: SnippetMatchParams = serde_json::from_str(json).unwrap();
assert_eq!(params.revision, "custom+org/repo$main");
assert_eq!(params.snippet, "1295019");
assert_eq!(params.path, "/src/a.rs");
}
}
Loading
Loading