diff --git a/.nudge.yaml b/.nudge.yaml index a386915..2c88a71 100644 --- a/.nudge.yaml +++ b/.nudge.yaml @@ -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 @@ -175,26 +175,10 @@ rules: pattern: '(?m)^\s*//\s*TODO:\s*(?P.+)$' 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 diff --git a/src/bin/fossapi.rs b/src/bin/fossapi.rs index 95898ef..dd52ff5 100644 --- a/src/bin/fossapi.rs +++ b/src/bin/fossapi.rs @@ -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; @@ -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(()) } @@ -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 = + 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 = paths.iter().map(SnippetPathRow::from).collect(); + println!("{}", Table::new(rows)); + } + } } Ok(()) } @@ -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, + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5ec3073..1fe19a8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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. @@ -133,6 +152,39 @@ pub enum ListCommand { #[arg(long)] count: Option, }, + #[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, + #[arg(long)] + page: Option, + #[arg(long)] + count: Option, + }, + #[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, + #[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, + }, } /// Entity types that can be operated on. diff --git a/src/lib.rs b/src/lib.rs index 8a264cb..f11d2bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, +}; diff --git a/src/mcp/params.rs b/src/mcp/params.rs index 4764713..7b722b8 100644 --- a/src/mcp/params.rs +++ b/src/mcp/params.rs @@ -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. @@ -48,6 +50,12 @@ pub struct ListParams { /// Issue category filter (required for Issue entity: vulnerability, licensing, quality). #[serde(default)] pub category: Option, + /// File/directory path filter (Snippet entity; defaults to the repository root). + #[serde(default)] + pub path: Option, + /// Resolve the first-party line range for each match (Snippet entity; extra API calls). + #[serde(default)] + pub with_lines: Option, } /// Parameters for the `update` MCP tool. @@ -71,6 +79,17 @@ pub struct UpdateParams { pub public: Option, } +/// 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::*; @@ -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"); + } } diff --git a/src/mcp/server.rs b/src/mcp/server.rs index d09d9d7..7e6be55 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -14,9 +14,10 @@ use schemars::JsonSchema; use std::sync::Arc; use crate::{ - mcp::{EntityType, GetParams, ListParams, UpdateParams}, + mcp::{EntityType, GetParams, ListParams, SnippetMatchParams, UpdateParams}, DependencyListQuery, FossaClient, FossaError, Get, Issue, IssueCategory, IssueListQuery, List, - Project, ProjectListQuery, ProjectUpdateParams, Revision, RevisionListQuery, Update, + Project, ProjectListQuery, ProjectUpdateParams, Revision, RevisionListQuery, SnippetListQuery, + Update, }; /// FOSSA MCP Server. @@ -149,6 +150,13 @@ impl FossaServer { None, )); } + EntityType::Snippet => { + return Err(McpError::invalid_params( + "Snippet does not support get. Use list with a parent revision locator, \ + or the snippet_match tool to drill into a single match.", + None, + )); + } }; Ok(CallToolResult::success(vec![Content::text(result)])) @@ -215,11 +223,44 @@ impl FossaServer { serde_json::to_string_pretty(&page_result) .map_err(|e| McpError::internal_error(e.to_string(), None))? } + EntityType::Snippet => { + let parent = params.parent.ok_or_else(|| { + McpError::invalid_params( + "parent is required for listing snippets (revision locator)", + None, + ) + })?; + let query = SnippetListQuery { + path: params.path.clone(), + ..Default::default() + }; + let with_lines = params.with_lines.unwrap_or(false); + let locations = + crate::get_snippet_locations(&self.client, &parent, query, with_lines) + .await + .map_err(Self::to_mcp_error)?; + serde_json::to_string_pretty(&locations) + .map_err(|e| McpError::internal_error(e.to_string(), None))? + } }; Ok(CallToolResult::success(vec![Content::text(result)])) } + /// Handle the `snippet_match` tool: drill into a single snippet match. + async fn handle_snippet_match( + &self, + params: SnippetMatchParams, + ) -> Result { + let details = + crate::get_snippet_match(&self.client, ¶ms.revision, ¶ms.snippet, ¶ms.path) + .await + .map_err(Self::to_mcp_error)?; + let result = serde_json::to_string_pretty(&details) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + Ok(CallToolResult::success(vec![Content::text(result)])) + } + /// Handle the `update` tool. async fn handle_update(&self, params: UpdateParams) -> Result { match params.entity { @@ -251,6 +292,10 @@ impl FossaServer { "Update not supported for Dependency", None, )), + EntityType::Snippet => Err(McpError::invalid_params( + "Update not supported for Snippet", + None, + )), } } } @@ -270,7 +315,10 @@ impl ServerHandler for FossaServer { version: env!("CARGO_PKG_VERSION").to_string(), }, instructions: Some( - "FOSSA API MCP Server - Query projects, revisions, issues, and dependencies." + "FOSSA API MCP Server - Query projects, revisions, issues, dependencies, and \ + snippet matches. Use list(entity: snippet, parent: ) to map \ + third-party code matches to first-party files, then snippet_match to drill into \ + a single match's first-party line numbers and side-by-side code." .to_string(), ), } @@ -295,7 +343,9 @@ impl ServerHandler for FossaServer { Projects: no parent needed. \ Revisions: parent = project locator. \ Issues: category required (vulnerability, licensing, quality). \ - Dependencies: parent = revision locator.", + Dependencies: parent = revision locator. \ + Snippets: parent = revision locator; optional path filter and with_lines \ + (resolves first-party line ranges). Returns one row per matched first-party file.", Self::schema::(), ), Tool::new( @@ -304,6 +354,13 @@ impl ServerHandler for FossaServer { Can update: title, description, url, public.", Self::schema::(), ), + Tool::new( + "snippet_match", + "Drill into a single snippet match. Given a revision locator, snippet ID, and \ + first-party file path (from list entity: snippet), returns the matched first-party \ + code (detected_code, with line numbers) alongside the open-source reference_code.", + Self::schema::(), + ), ]; Ok(ListToolsResult { @@ -338,6 +395,11 @@ impl ServerHandler for FossaServer { .map_err(|e| McpError::invalid_params(e.to_string(), None))?; self.handle_update(params).await } + "snippet_match" => { + let params: SnippetMatchParams = serde_json::from_value(args) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + self.handle_snippet_match(params).await + } other => Err(McpError::invalid_params( format!("Unknown tool: {other}"), None, @@ -349,7 +411,7 @@ impl ServerHandler for FossaServer { #[cfg(test)] mod tests { use super::*; - use wiremock::matchers::{method, path, query_param}; + use wiremock::matchers::{method, path, path_regex, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; #[test] @@ -431,6 +493,8 @@ mod tests { page: None, count: None, category: None, + path: None, + with_lines: None, }; let result = server.handle_list(params).await.unwrap(); @@ -485,6 +549,8 @@ mod tests { page: None, count: None, category: None, + path: None, + with_lines: None, }; let result = server.handle_list(params).await.unwrap(); @@ -523,6 +589,8 @@ mod tests { page: None, count: None, category: None, + path: None, + with_lines: None, }; let result = server.handle_list(params).await.unwrap(); @@ -543,6 +611,8 @@ mod tests { page: None, count: None, category: None, + path: None, + with_lines: None, }; let result = server.handle_list(params).await; @@ -566,6 +636,8 @@ mod tests { page: None, count: None, category: None, + path: None, + with_lines: None, }; let result = server.handle_list(params).await; @@ -602,6 +674,8 @@ mod tests { page: None, // Should default to 1 count: None, // Should default to 20 category: None, + path: None, + with_lines: None, }; let _ = server.handle_list(params).await; @@ -635,6 +709,8 @@ mod tests { page: Some(1), count: Some(200), // Should be capped to 100 category: None, + path: None, + with_lines: None, }; let _ = server.handle_list(params).await; @@ -1048,6 +1124,8 @@ mod tests { page: None, count: None, category: Some(IssueCategory::Vulnerability), + path: None, + with_lines: None, }; let result = server.handle_list(params).await.unwrap(); @@ -1067,6 +1145,8 @@ mod tests { page: None, count: None, category: None, // Missing required category + path: None, + with_lines: None, }; let result = server.handle_list(params).await; @@ -1074,4 +1154,136 @@ mod tests { let err = result.unwrap_err(); assert!(err.message.to_lowercase().contains("category")); } + + // ========================================================================= + // Snippet MCP handler tests + // ========================================================================= + + /// Test: list(entity: snippet, parent: revision) returns flattened locations. + #[tokio::test] + async fn handle_list_snippets_returns_locations() { + let mock_server = MockServer::start().await; + + let list_body = serde_json::json!({ + "results": [{ + "id": "55", "packageId": "7", "purl": "pkg:x", + "locator": "pod+x$1", "package": "X", "version": "1.0", + "kind": "file", "matchCount": 1 + }], + "totalCount": 1, "page": 1, "pageSize": 50 + }); + let details_body = serde_json::json!({ + "snippet": { + "id": "55", "packageId": "7", "purl": "pkg:x", + "locator": "pod+x$1", "package": "X", "version": "1.0", + "kind": "file", + "matches": [{"path": "/src/a.rs", "matchPercentage": 1}] + } + }); + + Mock::given(method("GET")) + .and(path("/revisions/custom%2Borg%2Frepo%24main/snippets")) + .respond_with(ResponseTemplate::new(200).set_body_json(&list_body)) + .mount(&mock_server) + .await; + Mock::given(method("GET")) + .and(path("/revisions/custom%2Borg%2Frepo%24main/snippets/55")) + .respond_with(ResponseTemplate::new(200).set_body_json(&details_body)) + .mount(&mock_server) + .await; + + let client = FossaClient::new("test-token", &mock_server.uri()).unwrap(); + let server = FossaServer::new(client); + + let params = ListParams { + entity: EntityType::Snippet, + parent: Some("custom+org/repo$main".to_string()), + page: None, + count: None, + category: None, + path: None, + with_lines: None, + }; + + let result = server.handle_list(params).await.unwrap(); + assert!(!result.is_error.unwrap_or(false)); + let text = result.content[0].raw.as_text().unwrap().text.as_str(); + assert!(text.contains("/src/a.rs")); + assert!(text.contains("\"snippet_id\": \"55\"")); + } + + /// Test: list(entity: snippet) without parent returns error. + #[tokio::test] + async fn handle_list_snippets_without_parent_returns_error() { + let mock_server = MockServer::start().await; + let client = FossaClient::new("test-token", &mock_server.uri()).unwrap(); + let server = FossaServer::new(client); + + let params = ListParams { + entity: EntityType::Snippet, + parent: None, + page: None, + count: None, + category: None, + path: None, + with_lines: None, + }; + + let err = server.handle_list(params).await.unwrap_err(); + assert!(err.message.to_lowercase().contains("parent")); + } + + /// Test: snippet_match drills into a single match and returns the code. + #[tokio::test] + async fn handle_snippet_match_returns_code() { + let mock_server = MockServer::start().await; + + let match_body = serde_json::json!({ + "matchDetails": { + "path": "/src/a.rs", + "matchPercentage": 100, + "referenceCode": [{"line": "x", "lineNumber": 1, "isHighlighted": true}], + "detectedCode": [{"line": "x", "lineNumber": 42, "isHighlighted": true}] + } + }); + + Mock::given(method("GET")) + .and(path_regex(r"/snippets/55/matches/")) + .respond_with(ResponseTemplate::new(200).set_body_json(&match_body)) + .expect(1) + .mount(&mock_server) + .await; + + let client = FossaClient::new("test-token", &mock_server.uri()).unwrap(); + let server = FossaServer::new(client); + + let params = SnippetMatchParams { + revision: "custom+org/repo$main".to_string(), + snippet: "55".to_string(), + path: "/src/a.rs".to_string(), + }; + + let result = server.handle_snippet_match(params).await.unwrap(); + assert!(!result.is_error.unwrap_or(false)); + let text = result.content[0].raw.as_text().unwrap().text.as_str(); + assert!(text.contains("detectedCode") || text.contains("detected_code")); + assert!(text.contains("42")); + } + + /// Test: snippet does not support get. + #[tokio::test] + async fn handle_get_snippet_returns_error() { + let client = FossaClient::new("test-token", "http://localhost:9999").unwrap(); + let server = FossaServer::new(client); + + let params = GetParams { + entity: EntityType::Snippet, + id: "55".to_string(), + category: None, + }; + + let err = server.handle_get(params).await.unwrap_err(); + let msg = format!("{err:?}"); + assert!(msg.contains("does not support get") || msg.contains("snippet_match")); + } } diff --git a/src/models/mod.rs b/src/models/mod.rs index 5a73379..f0e5faa 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -4,8 +4,10 @@ mod dependency; mod issue; mod project; mod revision; +mod snippet; pub use dependency::*; pub use issue::*; pub use project::*; pub use revision::*; +pub use snippet::*; diff --git a/src/models/snippet.rs b/src/models/snippet.rs new file mode 100644 index 0000000..f7d671b --- /dev/null +++ b/src/models/snippet.rs @@ -0,0 +1,856 @@ +//! Snippet model and trait implementations. +//! +//! Snippet scanning identifies where a project's first-party source code matches +//! third-party open-source code (a legal/license-compliance risk). Snippets are a +//! child of a [`Revision`](crate::Revision): the revision locator is part of the +//! URL path, so listing uses a `(revision_locator, filters)` query tuple — the same +//! shape as [`Dependency`](crate::Dependency). +//! +//! The high-value endpoint is the *match details* drill-in +//! ([`get_snippet_match`]): it returns the actual lines (with line numbers) in the +//! user's **first-party file** alongside the reference open-source code, which is +//! what lets a remediation team jump straight to the matched code in their own repo. + +#[cfg(test)] +mod tests { + use super::*; + + // Real responses captured from the live API (see tests/fixtures/snippets/SCHEMA.md). + const SNIPPETS_JSON: &str = include_str!("../../tests/fixtures/snippets/snippets.json"); + const DETAILS_JSON: &str = include_str!("../../tests/fixtures/snippets/snippet-details.json"); + const PATHS_JSON: &str = include_str!("../../tests/fixtures/snippets/paths.json"); + const MATCH_SMALL_JSON: &str = + include_str!("../../tests/fixtures/snippets/match-details-small.json"); + const MATCH_PARTIAL_JSON: &str = + include_str!("../../tests/fixtures/snippets/match-details-partial.json"); + + #[test] + fn test_snippet_list_response_deserialize() { + let resp: SnippetListResponse = + serde_json::from_str(SNIPPETS_JSON).expect("list deserialize"); + assert_eq!(resp.results.len(), 3); + assert_eq!(resp.total_count, Some(3)); + assert_eq!(resp.page_size, Some(10)); + + let first = &resp.results[0]; + // id is a STRING, not a number. + assert_eq!(first.id, "1295019"); + assert_eq!(first.package, "Alamofire"); + assert_eq!(first.version, "5.11.0"); + assert_eq!(first.purl, "pkg:cocoapods/Alamofire@5.11.0"); + assert!(matches!(first.kind, SnippetKind::File)); + assert_eq!(first.match_count, 1); + assert_eq!(first.license_ids(), vec!["MIT"]); + // First result is rejected in this fixture. + assert!(first.rejection_details.is_some()); + // Extra fields confirmed live. + assert!(!first.is_vendored); + assert!(!first.is_converted); + assert_eq!( + first.home_url.as_deref(), + Some("https://github.com/alamofire/alamofire") + ); + + // Second result is vendored/converted and not rejected. + let second = &resp.results[1]; + assert!(second.is_vendored); + assert!(second.is_converted); + assert!(second.rejection_details.is_none()); + } + + #[test] + fn test_snippet_issue_counts_deserialize() { + let resp: SnippetListResponse = serde_json::from_str(SNIPPETS_JSON).unwrap(); + let counts = &resp.results[0].issue_counts; + assert_eq!(counts.licensing.flagged, 0); + assert_eq!(counts.security.critical, 0); + } + + #[test] + fn test_snippet_details_deserialize() { + let resp: SnippetDetailsResponse = + serde_json::from_str(DETAILS_JSON).expect("details deserialize"); + let snippet = resp.snippet; + assert_eq!(snippet.id, "1295019"); + + // Details payload populates matches + otherVersions (absent from the list payload). + assert_eq!(snippet.matches.len(), 1); + assert_eq!(snippet.matches[0].path, "/Sources/Networking/Session.swift"); + assert_eq!(snippet.matches[0].match_percentage, 1.0); + + assert_eq!(snippet.other_versions.len(), 2); + assert_eq!(snippet.other_versions[0].version, "5.9.1"); + assert_eq!(snippet.other_versions[0].match_count, 1); + } + + #[test] + fn test_snippet_list_payload_has_no_matches() { + // The lighter list payload omits matches/otherVersions; #[serde(default)] must cover it. + let resp: SnippetListResponse = serde_json::from_str(SNIPPETS_JSON).unwrap(); + assert!(resp.results[0].matches.is_empty()); + assert!(resp.results[0].other_versions.is_empty()); + } + + #[test] + fn test_snippet_paths_deserialize() { + let resp: SnippetPathsResponse = + serde_json::from_str(PATHS_JSON).expect("paths deserialize"); + assert_eq!(resp.paths.len(), 1); + let p = &resp.paths[0]; + assert_eq!(p.path_type, "directory"); + assert_eq!(p.name, "Sources"); + assert_eq!(p.path, "/Sources"); + assert_eq!(p.count, 3); + } + + #[test] + fn test_match_details_deserialize() { + let resp: SnippetMatchDetailsResponse = + serde_json::from_str(MATCH_SMALL_JSON).expect("match details deserialize"); + let md = resp.match_details; + assert_eq!(md.path, "/Sources/Networking/Session.swift"); + assert_eq!(md.reference_code.len(), 6); + assert_eq!(md.detected_code.len(), 6); + + let line = &md.detected_code[0]; + assert_eq!(line.line_number, 1); + assert!(line.is_highlighted); + assert_eq!(line.line, "//"); + } + + #[test] + fn test_match_details_percentage_is_0_to_100_scale() { + // The match-details endpoint reports matchPercentage on a 0-100 scale, + // unlike the 0-1 scale used by the list/details endpoints. + let resp: SnippetMatchDetailsResponse = serde_json::from_str(MATCH_SMALL_JSON).unwrap(); + assert_eq!(resp.match_details.match_percentage, 100.0); + } + + #[test] + fn test_detected_line_range_partial() { + // A partial (kind:"snippet") match: only a sub-range of the file is highlighted. + let resp: SnippetMatchDetailsResponse = serde_json::from_str(MATCH_PARTIAL_JSON).unwrap(); + let md = resp.match_details; + assert_eq!(md.detected_line_range(), Some((41, 42))); + assert_eq!(md.reference_line_range(), Some((13, 14))); + } + + #[test] + fn test_detected_line_range_none_when_no_highlight() { + let md = SnippetMatchDetails { + path: "x".into(), + match_percentage: 0.5, + reference_code: vec![], + detected_code: vec![CodeLine { + line: "a".into(), + line_number: 7, + is_highlighted: false, + }], + }; + assert_eq!(md.detected_line_range(), None); + } + + #[test] + fn test_line_range_ignores_trailing_blank_highlight() { + let md = SnippetMatchDetails { + path: "x".into(), + match_percentage: 100.0, + reference_code: vec![], + detected_code: vec![ + CodeLine { + line: "fn a() {}".into(), + line_number: 10, + is_highlighted: true, + }, + CodeLine { + line: String::new(), + line_number: 11, + is_highlighted: true, + }, + ], + }; + assert_eq!(md.detected_line_range(), Some((10, 10))); + } + + #[test] + fn test_snippet_kind_unknown_is_lenient() { + let s: Snippet = serde_json::from_value(serde_json::json!({ + "id": "1", "packageId": "1", "purl": "p", "locator": "l", + "package": "pkg", "version": "1.0", "kind": "somethingNew" + })) + .expect("unknown kind should deserialize"); + assert!(matches!(s.kind, SnippetKind::Unknown)); + } + + #[test] + fn test_snippet_minimal_deserialize() { + // Only the core identity fields are guaranteed; everything else must default. + let s: Snippet = serde_json::from_value(serde_json::json!({ + "id": "1", "packageId": "1", "purl": "p", "locator": "l", + "package": "pkg", "version": "1.0", "kind": "file" + })) + .expect("minimal deserialize"); + assert_eq!(s.match_count, 0); + assert!(s.licenses.is_empty()); + assert!(s.matches.is_empty()); + assert_eq!(s.highest_match_percentage, 0.0); + } + + #[test] + fn test_snippet_list_query_serializes_only_set_fields() { + // Unset fields must be skipped (the API rejects empty filter values). + let q = SnippetListQuery { + path: Some("/Sources".into()), + search: None, + sort: None, + }; + let value = serde_json::to_value(&q).unwrap(); + let obj = value.as_object().unwrap(); + assert_eq!(obj.len(), 1); + assert_eq!(obj.get("path").unwrap(), "/Sources"); + } + + #[test] + fn test_with_default_path_fills_root() { + assert_eq!( + SnippetListQuery::default().with_default_path().path.as_deref(), + Some("/") + ); + assert_eq!( + SnippetListQuery { + path: Some("/Sources".into()), + ..Default::default() + } + .with_default_path() + .path + .as_deref(), + Some("/Sources") + ); + } +} + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::client::FossaClient; +use crate::error::{FossaError, Result}; +use crate::pagination::Page; +use crate::traits::List; + +/// Maximum page size accepted by the snippets endpoint (`pageSize`, 1-50). +const SNIPPET_MAX_PAGE_SIZE: u32 = 50; + +/// The kind of a snippet match. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SnippetKind { + /// A whole-file match. + File, + /// A partial (sub-file) snippet match. + Snippet, + /// An unrecognized kind (forward-compatible). + #[serde(other)] + Unknown, +} + +/// A snippet: a third-party open-source package matched against first-party code. +/// +/// Returned by the snippet listing (`GET /revisions/{locator}/snippets`) and the +/// details endpoint (`GET /revisions/{locator}/snippets/{id}`). The `matches` and +/// `other_versions` fields are only populated by the details endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Snippet { + /// Snippet identifier (a numeric string, e.g. "1295019"). + pub id: String, + + /// The matched package's identifier. + pub package_id: String, + + /// Package URL (e.g. "pkg:cocoapods/Alamofire@5.11.0"). + pub purl: String, + + /// The matched package locator (e.g. "pod+Alamofire$5.11.0"). + pub locator: String, + + /// The matched package name. + pub package: String, + + /// The matched package version. + pub version: String, + + /// Whether this is a whole-file or partial snippet match. + pub kind: SnippetKind, + + /// Highest match percentage across this snippet's matches (0.0-1.0). + #[serde(default)] + pub highest_match_percentage: f64, + + /// Number of files this snippet matched. + #[serde(default)] + pub match_count: u32, + + /// Licenses associated with the matched package. + #[serde(default)] + pub licenses: Vec, + + /// Licensing and security issue counts for the matched package. + #[serde(default)] + pub issue_counts: SnippetIssueCounts, + + /// Rejection status, if this snippet has been rejected. + #[serde(default)] + pub rejection_details: Option, + + /// Package labels. + #[serde(default)] + pub labels: Vec, + + /// Package homepage URL. + #[serde(default)] + pub home_url: Option, + + /// Package source code URL. + #[serde(default)] + pub code_url: Option, + + /// Package release date. + #[serde(default)] + pub release_date: Option>, + + /// Whether the matched code is vendored into the project. + #[serde(default)] + pub is_vendored: bool, + + /// Whether the matched code was converted. + #[serde(default)] + pub is_converted: bool, + + /// Per-file matches (populated only by the details endpoint). + #[serde(default)] + pub matches: Vec, + + /// Other versions of the matched package that also match (details endpoint only). + #[serde(default)] + pub other_versions: Vec, +} + +impl Snippet { + /// License identifiers (signatures) for the matched package. + pub fn license_ids(&self) -> Vec { + self.licenses.iter().map(|l| l.signature.clone()).collect() + } + + /// Whether this snippet has been rejected. + pub fn is_rejected(&self) -> bool { + self.rejection_details.is_some() + } +} + +/// A license associated with a matched snippet package. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnippetLicense { + /// The license identifier/signature (e.g. "MIT"). + pub signature: String, + + /// How the license was determined (e.g. "declared", "discovered"). + #[serde(rename = "type", default)] + pub license_type: Option, + + /// Policy status (e.g. "approved", "denied", "flagged", "unknown"). + #[serde(default)] + pub status: Option, + + /// Associated issue identifier, if any. + #[serde(default)] + pub issue_id: Option, +} + +/// Licensing and security issue counts for a matched package. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SnippetIssueCounts { + /// Licensing issue counts. + #[serde(default)] + pub licensing: SnippetLicensingCounts, + + /// Security issue counts. + #[serde(default)] + pub security: SnippetSecurityCounts, +} + +/// Licensing issue counts. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SnippetLicensingCounts { + /// Number of denied licensing issues. + #[serde(default)] + pub denied: u32, + /// Number of flagged licensing issues. + #[serde(default)] + pub flagged: u32, + /// Number of unknown licensing issues. + #[serde(default)] + pub unknown: u32, +} + +/// Security issue counts by severity. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SnippetSecurityCounts { + /// Critical-severity issues. + #[serde(default)] + pub critical: u32, + /// High-severity issues. + #[serde(default)] + pub high: u32, + /// Medium-severity issues. + #[serde(default)] + pub medium: u32, + /// Low-severity issues. + #[serde(default)] + pub low: u32, + /// Issues of unknown severity. + #[serde(default)] + pub unknown: u32, +} + +/// Rejection details for a snippet or match. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnippetRejection { + /// When the snippet was rejected. + #[serde(default)] + pub rejected_at: Option>, + /// Who rejected the snippet. + #[serde(default)] + pub rejected_by: Option, +} + +/// A package label assignment. Fields are modeled leniently as the shape varies; +/// unknown fields are ignored. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnippetLabel { + /// Label name. + #[serde(default)] + pub name: Option, + /// Label identifier. + #[serde(default)] + pub label_id: Option, + /// Label scope. + #[serde(default)] + pub scope: Option, +} + +/// Another version of a matched package that also matched (details endpoint). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnippetOtherVersion { + /// The other version string. + pub version: String, + /// Number of matches for that version. + #[serde(default)] + pub match_count: u32, +} + +/// A single first-party file where a snippet matched (from the details endpoint). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnippetMatch { + /// The first-party file path. + pub path: String, + /// Match percentage for this file (0.0-1.0). + #[serde(default)] + pub match_percentage: f64, + /// Rejection status for this match, if rejected. + #[serde(default)] + pub rejection_details: Option, +} + +/// An entry from the snippet path tree (`GET /revisions/{locator}/snippets/paths`). +/// +/// The tree is hierarchical: querying `path=/` yields directories with a `count`; +/// drill in by querying a directory's `path` to reach files. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnippetPath { + /// Either "directory" or "file". + #[serde(rename = "type")] + pub path_type: String, + /// The entry name (file or directory name). + pub name: String, + /// The full path of the entry. + pub path: String, + /// Number of snippets at or under this entry. + #[serde(default)] + pub count: u32, +} + +impl SnippetPath { + /// Whether this entry is a file (vs a directory). + pub fn is_file(&self) -> bool { + self.path_type == "file" + } +} + +/// A single line of code in a match comparison. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeLine { + /// The line text. + pub line: String, + /// The 1-indexed line number in its file. + pub line_number: u32, + /// Whether this line is part of the highlighted match. + #[serde(default)] + pub is_highlighted: bool, +} + +/// The drill-in payload for a single snippet match +/// (`GET /revisions/{locator}/snippets/{id}/matches/{path}`). +/// +/// `detected_code` is the user's **first-party** code (with line numbers); +/// `reference_code` is the matched **open-source** code. This is what lets a team +/// locate the matched code in their own repository. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnippetMatchDetails { + /// The first-party file path. + pub path: String, + /// Overall match percentage on a 0-100 scale. + /// + /// NOTE: this endpoint reports the percentage as 0-100 (e.g. `100`), unlike + /// [`Snippet::highest_match_percentage`] and [`SnippetMatch::match_percentage`] + /// which use a 0-1 scale. The value is kept as the API returns it. + #[serde(default)] + pub match_percentage: f64, + /// Reference (open-source) code lines. + #[serde(default)] + pub reference_code: Vec, + /// Detected (first-party) code lines. + #[serde(default)] + pub detected_code: Vec, +} + +impl SnippetMatchDetails { + fn highlighted_range(lines: &[CodeLine]) -> Option<(u32, u32)> { + let nums = lines + .iter() + .filter(|l| l.is_highlighted && !l.line.trim().is_empty()) + .map(|l| l.line_number) + .collect::>(); + match (nums.iter().min(), nums.iter().max()) { + (Some(&lo), Some(&hi)) => Some((lo, hi)), + _ => None, + } + } + + /// The (start, end) first-party line range of the highlighted match, if any. + pub fn detected_line_range(&self) -> Option<(u32, u32)> { + Self::highlighted_range(&self.detected_code) + } + + /// The (start, end) reference (open-source) line range of the highlighted match. + pub fn reference_line_range(&self) -> Option<(u32, u32)> { + Self::highlighted_range(&self.reference_code) + } +} + +/// A flattened remediation row: one matched first-party file location. +/// +/// Produced by [`get_snippet_locations`]; this is the "where in my repo is this?" +/// map. The line range is only populated when requested (it requires a drill-in +/// call per match). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SnippetLocation { + /// The first-party file path where the match was found. + pub path: String, + /// The snippet identifier (use with [`get_snippet_match`] to drill in). + pub snippet_id: String, + /// The matched package name. + pub package: String, + /// The matched package version. + pub version: String, + /// The matched package URL. + pub purl: String, + /// Match percentage for this file (0.0-1.0). + pub match_percentage: f64, + /// License identifiers of the matched package. + pub licenses: Vec, + /// First-party start line (only when computed via `with_lines`). + #[serde(skip_serializing_if = "Option::is_none")] + pub line_start: Option, + /// First-party end line (only when computed via `with_lines`). + #[serde(skip_serializing_if = "Option::is_none")] + pub line_end: Option, +} + +/// Query parameters for listing snippets / snippet paths. +/// +/// `path` is required by the API; the convenience functions default it to `/` when +/// unset. Array filters (ids, packageIds, rejectionStatus, packageLabels) are not +/// modeled in v1 because the query is serialized via `serde_urlencoded`, which does +/// not support sequence fields. +#[derive(Debug, Clone, Default, Serialize)] +pub struct SnippetListQuery { + /// Filter by file/directory path (required by the API; defaults to `/`). + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Search term for filtering by package name. + #[serde(skip_serializing_if = "Option::is_none")] + pub search: Option, + /// Sort order (package_asc, package_desc, matchCount_asc, matchCount_desc). + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, +} + +impl SnippetListQuery { + /// Return a copy with `path` defaulted to `/` when unset (the API requires it). + fn with_default_path(&self) -> Self { + let mut q = self.clone(); + if q.path.is_none() { + q.path = Some("/".to_string()); + } + q + } +} + +/// Query type for snippet listing (includes the revision locator). +pub type SnippetQuery = (String, SnippetListQuery); + +/// API response wrapper for listing snippets. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SnippetListResponse { + #[serde(default)] + results: Vec, + #[serde(default)] + total_count: Option, + #[serde(default)] + #[allow(dead_code)] + page: Option, + #[serde(default)] + #[allow(dead_code)] + page_size: Option, +} + +/// API response wrapper for snippet details. +#[derive(Debug, Deserialize)] +struct SnippetDetailsResponse { + snippet: Snippet, +} + +/// API response wrapper for the snippet path tree. +#[derive(Debug, Deserialize)] +struct SnippetPathsResponse { + #[serde(default)] + paths: Vec, +} + +/// API response wrapper for match details. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SnippetMatchDetailsResponse { + match_details: SnippetMatchDetails, +} + +#[async_trait] +impl List for Snippet { + type Query = SnippetQuery; // (revision_locator, filters) + + #[tracing::instrument(skip(client))] + async fn list_page( + client: &FossaClient, + query: &Self::Query, + page: u32, + count: u32, + ) -> Result> { + let (revision_locator, filters) = query; + let encoded_locator = urlencoding::encode(revision_locator); + let path = format!("revisions/{encoded_locator}/snippets"); + + // The API caps pageSize at 50. + let page_size = count.clamp(1, SNIPPET_MAX_PAGE_SIZE); + let effective = filters.with_default_path(); + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct RequestParams<'a> { + #[serde(flatten)] + query: &'a SnippetListQuery, + page: u32, + page_size: u32, + } + + let params = RequestParams { + query: &effective, + page, + page_size, + }; + + let response = client.get_with_query(&path, ¶ms).await?; + let data: SnippetListResponse = response.json().await.map_err(FossaError::HttpError)?; + + // Report has_more using the actual page size used, so list_all paginates correctly. + Ok(Page::new(data.results, page, page_size, data.total_count)) + } + + /// Override the default `list_all`: it paginates at `DEFAULT_PAGE_SIZE` (100) and + /// stops on the first short page, which would be wrong here since the API caps + /// pages at 50. Page at 50 and rely on `has_more` (derived from `totalCount`). + async fn list_all(client: &FossaClient, query: &Self::Query) -> Result> { + let mut all = Vec::new(); + let mut page = 1u32; + loop { + let result = Self::list_page(client, query, page, SNIPPET_MAX_PAGE_SIZE).await?; + let count = result.items.len(); + all.extend(result.items); + if !result.has_more || count == 0 { + break; + } + page += 1; + if page > 1000 { + tracing::warn!("Reached snippet pagination limit, stopping"); + break; + } + } + Ok(all) + } +} + +// ----------------------------------------------------------------------------- +// Convenience functions +// ----------------------------------------------------------------------------- + +/// Fetch all snippets for a revision (all pages). +pub async fn get_snippets( + client: &FossaClient, + revision_locator: &str, + query: SnippetListQuery, +) -> Result> { + Snippet::list_all(client, &(revision_locator.to_string(), query)).await +} + +/// Fetch a single page of snippets for a revision. +pub async fn get_snippets_page( + client: &FossaClient, + revision_locator: &str, + query: SnippetListQuery, + page: u32, + count: u32, +) -> Result> { + Snippet::list_page(client, &(revision_locator.to_string(), query), page, count).await +} + +/// Fetch the snippet path tree for a revision. +/// +/// The tree is hierarchical — pass a directory `path` in the query to drill in. +/// When `path` is unset it defaults to `/` (the repository root). +pub async fn get_snippet_paths( + client: &FossaClient, + revision_locator: &str, + query: SnippetListQuery, +) -> Result> { + let encoded_locator = urlencoding::encode(revision_locator); + let path = format!("revisions/{encoded_locator}/snippets/paths"); + let effective = query.with_default_path(); + let response = client.get_with_query(&path, &effective).await?; + let data: SnippetPathsResponse = response.json().await.map_err(FossaError::HttpError)?; + Ok(data.paths) +} + +/// Fetch full details for a single snippet, including its per-file `matches`. +pub async fn get_snippet_details( + client: &FossaClient, + revision_locator: &str, + snippet_id: &str, +) -> Result { + let encoded_locator = urlencoding::encode(revision_locator); + let encoded_id = urlencoding::encode(snippet_id); + let path = format!("revisions/{encoded_locator}/snippets/{encoded_id}"); + let response = client.get(&path).await?; + let data: SnippetDetailsResponse = response.json().await.map_err(FossaError::HttpError)?; + Ok(data.snippet) +} + +/// Fetch the match-details drill-in for a snippet at a specific first-party path. +/// +/// Returns the first-party (`detected_code`) and open-source (`reference_code`) +/// lines with line numbers — the data needed to locate the match in the repo. +pub async fn get_snippet_match( + client: &FossaClient, + revision_locator: &str, + snippet_id: &str, + match_path: &str, +) -> Result { + let encoded_locator = urlencoding::encode(revision_locator); + let encoded_id = urlencoding::encode(snippet_id); + let encoded_path = urlencoding::encode(match_path); + let path = + format!("revisions/{encoded_locator}/snippets/{encoded_id}/matches/{encoded_path}"); + let response = client.get(&path).await?; + let data: SnippetMatchDetailsResponse = + response.json().await.map_err(FossaError::HttpError)?; + Ok(data.match_details) +} + +/// Build a flat list of every snippet match location in a revision. +/// +/// This is the aggregating "where in my repo is this?" report: it lists all +/// snippets, fetches each snippet's details to enumerate its matched files, and +/// emits one [`SnippetLocation`] per (snippet, file). +/// +/// When `with_lines` is true, it additionally drills into each match to compute the +/// highlighted first-party line range — at the cost of one extra (potentially large) +/// request per match. Drill-in failures degrade gracefully to an empty line range. +/// +/// Cost: ~1 list + N details calls (one per snippet), plus M match-details calls +/// when `with_lines` is set. Scope the work with the query's `path`/`search` filters. +pub async fn get_snippet_locations( + client: &FossaClient, + revision_locator: &str, + query: SnippetListQuery, + with_lines: bool, +) -> Result> { + let snippets = get_snippets(client, revision_locator, query).await?; + + let mut locations = Vec::new(); + for snippet in &snippets { + let details = get_snippet_details(client, revision_locator, &snippet.id).await?; + let licenses = details.license_ids(); + for m in &details.matches { + let (line_start, line_end) = if with_lines { + match get_snippet_match(client, revision_locator, &snippet.id, &m.path).await { + Ok(md) => match md.detected_line_range() { + Some((lo, hi)) => (Some(lo), Some(hi)), + None => (None, None), + }, + Err(e) => { + tracing::warn!( + "Failed to fetch match details for {} at {}: {e}", + snippet.id, + m.path + ); + (None, None) + } + } + } else { + (None, None) + }; + + locations.push(SnippetLocation { + path: m.path.clone(), + snippet_id: snippet.id.clone(), + package: details.package.clone(), + version: details.version.clone(), + purl: details.purl.clone(), + match_percentage: m.match_percentage, + licenses: licenses.clone(), + line_start, + line_end, + }); + } + } + + Ok(locations) +} diff --git a/src/output.rs b/src/output.rs index aa02e83..04f3e60 100644 --- a/src/output.rs +++ b/src/output.rs @@ -3,7 +3,9 @@ //! Provides the [`PrettyPrint`] trait for human-readable output //! as an alternative to JSON serialization. -use crate::{Issue, Project, Revision}; +use std::collections::BTreeSet; + +use crate::{CodeLine, Issue, Project, Revision, Snippet, SnippetKind, SnippetMatchDetails}; /// Trait for human-readable key-value output. /// @@ -124,6 +126,125 @@ impl PrettyPrint for Issue { } } +fn snippet_kind_label(kind: SnippetKind) -> &'static str { + match kind { + SnippetKind::File => "file (whole-file match)", + SnippetKind::Snippet => "snippet (partial match)", + SnippetKind::Unknown => "unknown", + } +} + +impl PrettyPrint for Snippet { + fn pretty_print(&self) -> String { + let header = format!("Snippet #{}", self.id); + let divider = "─".repeat(header.len().max(30)); + + let mut lines = vec![ + header, + divider, + format!("Package: {} {}", self.package, self.version), + format!("PURL: {}", self.purl), + format!("Kind: {}", snippet_kind_label(self.kind)), + format!( + "Match: {:.0}% (top), {} file(s)", + self.highest_match_percentage * 100.0, + self.match_count + ), + ]; + + let license_ids = self.license_ids(); + if !license_ids.is_empty() { + lines.push(format!("Licenses: {}", license_ids.join(", "))); + } + + if self.is_rejected() { + let by = self + .rejection_details + .as_ref() + .and_then(|r| r.rejected_by.as_deref()) + .unwrap_or("unknown"); + lines.push(format!("Rejected: yes (by {by})")); + } + + if !self.matches.is_empty() { + lines.push(String::new()); + lines.push("Matched files:".to_string()); + for m in &self.matches { + lines.push(format!(" {:.0}% {}", m.match_percentage * 100.0, m.path)); + } + } + + lines.join("\n") + } +} + +impl PrettyPrint for SnippetMatchDetails { + fn pretty_print(&self) -> String { + let header = format!("Snippet match: {}", self.path); + let divider = "─".repeat(header.len().max(30)); + + let mut lines = vec![header, divider]; + lines.push(format!("Match: {:.0}%", self.match_percentage)); + if let Some((lo, hi)) = self.detected_line_range() { + lines.push(format!("Your code: lines {lo}-{hi}")); + } + if let Some((lo, hi)) = self.reference_line_range() { + lines.push(format!("Reference: lines {lo}-{hi}")); + } + + lines.push(String::new()); + lines.push("── Detected (your code) ──".to_string()); + lines.extend(render_code_block(&self.detected_code)); + lines.push(String::new()); + lines.push("── Reference (open source) ──".to_string()); + lines.extend(render_code_block(&self.reference_code)); + + lines.join("\n") + } +} + +fn visible_indices(code: &[CodeLine], context: usize) -> BTreeSet { + let highlighted = code + .iter() + .enumerate() + .filter(|(_, l)| l.is_highlighted) + .map(|(i, _)| i) + .collect::>(); + + if highlighted.is_empty() { + return (0..code.len()).collect(); + } + + let mut shown = BTreeSet::new(); + for i in highlighted { + let lo = i.saturating_sub(context); + let hi = (i + context).min(code.len() - 1); + shown.extend(lo..=hi); + } + shown +} + +fn render_code_block(code: &[CodeLine]) -> Vec { + const CONTEXT: usize = 3; + + if code.is_empty() { + return vec![" (no code)".to_string()]; + } + + let mut out = Vec::new(); + let mut prev = None; + for i in visible_indices(code, CONTEXT) { + if prev.is_some_and(|p| i > p + 1) { + out.push(" ⋮".to_string()); + } + let l = &code[i]; + let gutter = if l.is_highlighted { '>' } else { ' ' }; + out.push(format!("{gutter} {:>5} │ {}", l.line_number, l.line)); + prev = Some(i); + } + out +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/fixtures/snippets/SCHEMA.md b/tests/fixtures/snippets/SCHEMA.md new file mode 100644 index 0000000..04e4190 --- /dev/null +++ b/tests/fixtures/snippets/SCHEMA.md @@ -0,0 +1,31 @@ +# Snippet API — confirmed schema (live spike 2026-06-10) + +Probed against `custom+58216/github.com/fossas/hack-house-docs$eda8d18...` (Alamofire snippets). +All endpoints `GET`, base `https://app.fossa.com/api`, bearer auth. Fixtures in this dir are real +responses (match-details trimmed to `-small`/`-partial` for test size). + +## Deltas from the documented hypothesis (important) +- Snippet **`id` is a STRING** (`"1295019"`), not a number. +- List wrapper paginates with **`pageSize`** (not `count`): `{results, totalCount, page, pageSize}`. +- Extra Snippet fields present: `homeUrl`, `codeUrl`, `isVendored`, `isConverted`, `releaseDate?`. +- `licenses[]` items are `{signature, type}` (e.g. `type:"declared"`); `status`/`issueId` optional. +- `/snippets/paths` is **hierarchical**: `path=/` returns a directory with a `count`; you drill + (`path=/Sources` → `/Sources/Networking` → files). `type` ∈ {`directory`,`file`}. +- Line numbers exist **only** in the match-details drill-in. Neither the list nor snippet-details + carries per-match line ranges (details `matches[]` = `{path, matchPercentage, rejectionDetails?}`). +- For `kind:"file"` matches every line `isHighlighted` (whole-file match). Partial ranges only occur + for `kind:"snippet"`. + +## Shapes +- `GET …/snippets/paths?path=P` → `{paths:[{type,name,path,count}]}` +- `GET …/snippets?path=P&page&pageSize&sort` → `{results:[Snippet],totalCount,page,pageSize}` + - Snippet (list): `{id,packageId,purl,locator,package,version,kind,highestMatchPercentage, + homeUrl?,codeUrl?,releaseDate?,labels[],rejectionDetails?,licenses[],issueCounts, + isVendored,isConverted,matchCount}` + - `issueCounts`: `{licensing:{denied,flagged,unknown}, security:{critical,high,medium,low,unknown}}` + - `rejectionDetails`: `{rejectedAt, rejectedBy}` +- `GET …/snippets/{id}` → `{snippet: Snippet + matches[] + otherVersions[]}` + - `matches[]`: `{path, matchPercentage, rejectionDetails?}` + - `otherVersions[]`: `{version, matchCount}` +- `GET …/snippets/{id}/matches/{urlencoded-path}` → `{matchDetails:{path,matchPercentage, + referenceCode[],detectedCode[]}}`; each code line `{line, lineNumber, isHighlighted}`. diff --git a/tests/fixtures/snippets/match-details-partial.json b/tests/fixtures/snippets/match-details-partial.json new file mode 100644 index 0000000..21c0abd --- /dev/null +++ b/tests/fixtures/snippets/match-details-partial.json @@ -0,0 +1,50 @@ +{ + "matchDetails": { + "path": "/Sources/Networking/Session.swift", + "matchPercentage": 0.91, + "referenceCode": [ + { + "line": "func encode() {", + "lineNumber": 12, + "isHighlighted": false + }, + { + "line": " let data = serialize(self)", + "lineNumber": 13, + "isHighlighted": true + }, + { + "line": " return data", + "lineNumber": 14, + "isHighlighted": true + }, + { + "line": "}", + "lineNumber": 15, + "isHighlighted": false + } + ], + "detectedCode": [ + { + "line": "func encode() {", + "lineNumber": 40, + "isHighlighted": false + }, + { + "line": " let data = serialize(self)", + "lineNumber": 41, + "isHighlighted": true + }, + { + "line": " return data", + "lineNumber": 42, + "isHighlighted": true + }, + { + "line": "}", + "lineNumber": 43, + "isHighlighted": false + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/snippets/match-details-small.json b/tests/fixtures/snippets/match-details-small.json new file mode 100644 index 0000000..f9f08c7 --- /dev/null +++ b/tests/fixtures/snippets/match-details-small.json @@ -0,0 +1,70 @@ +{ + "matchDetails": { + "path": "/Sources/Networking/Session.swift", + "matchPercentage": 100, + "referenceCode": [ + { + "line": "//", + "lineNumber": 1, + "isHighlighted": true + }, + { + "line": "// Session.swift", + "lineNumber": 2, + "isHighlighted": true + }, + { + "line": "//", + "lineNumber": 3, + "isHighlighted": true + }, + { + "line": "// Copyright (c) 2014-2018 Alamofire Software Foundation (http://alamofire.org/)", + "lineNumber": 4, + "isHighlighted": true + }, + { + "line": "//", + "lineNumber": 5, + "isHighlighted": true + }, + { + "line": "// Permission is hereby granted, free of charge, to any person obtaining a copy", + "lineNumber": 6, + "isHighlighted": true + } + ], + "detectedCode": [ + { + "line": "//", + "lineNumber": 1, + "isHighlighted": true + }, + { + "line": "// Session.swift", + "lineNumber": 2, + "isHighlighted": true + }, + { + "line": "//", + "lineNumber": 3, + "isHighlighted": true + }, + { + "line": "// Copyright (c) 2014-2018 Alamofire Software Foundation (http://alamofire.org/)", + "lineNumber": 4, + "isHighlighted": true + }, + { + "line": "//", + "lineNumber": 5, + "isHighlighted": true + }, + { + "line": "// Permission is hereby granted, free of charge, to any person obtaining a copy", + "lineNumber": 6, + "isHighlighted": true + } + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/snippets/paths-sources.json b/tests/fixtures/snippets/paths-sources.json new file mode 100644 index 0000000..cafa07b --- /dev/null +++ b/tests/fixtures/snippets/paths-sources.json @@ -0,0 +1,10 @@ +{ + "paths": [ + { + "type": "directory", + "name": "Networking", + "path": "/Sources/Networking", + "count": 3 + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/snippets/paths.json b/tests/fixtures/snippets/paths.json new file mode 100644 index 0000000..1d5acbd --- /dev/null +++ b/tests/fixtures/snippets/paths.json @@ -0,0 +1,10 @@ +{ + "paths": [ + { + "type": "directory", + "name": "Sources", + "path": "/Sources", + "count": 3 + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/snippets/snippet-details.json b/tests/fixtures/snippets/snippet-details.json new file mode 100644 index 0000000..3f7853c --- /dev/null +++ b/tests/fixtures/snippets/snippet-details.json @@ -0,0 +1,62 @@ +{ + "snippet": { + "id": "1295019", + "packageId": "724687", + "purl": "pkg:cocoapods/Alamofire@5.11.0", + "locator": "pod+Alamofire$5.11.0", + "package": "Alamofire", + "version": "5.11.0", + "kind": "file", + "matches": [ + { + "path": "/Sources/Networking/Session.swift", + "matchPercentage": 1, + "rejectionDetails": { + "rejectedAt": "2026-06-10T20:13:11.038Z", + "rejectedBy": "sara@fossa.com" + } + } + ], + "highestMatchPercentage": 1, + "homeUrl": "https://github.com/alamofire/alamofire", + "codeUrl": "https://github.com/alamofire/alamofire", + "otherVersions": [ + { + "version": "5.9.1", + "matchCount": 1 + }, + { + "version": "5.11.2", + "matchCount": 1 + } + ], + "labels": [], + "rejectionDetails": { + "rejectedAt": "2026-06-10T20:13:11.038Z", + "rejectedBy": "sara@fossa.com" + }, + "licenses": [ + { + "signature": "MIT", + "type": "declared" + } + ], + "issueCounts": { + "licensing": { + "denied": 0, + "flagged": 0, + "unknown": 0 + }, + "security": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "unknown": 0 + } + }, + "isVendored": false, + "isConverted": false, + "matchCount": 1 + } +} \ No newline at end of file diff --git a/tests/fixtures/snippets/snippets.json b/tests/fixtures/snippets/snippets.json new file mode 100644 index 0000000..ecc9e62 --- /dev/null +++ b/tests/fixtures/snippets/snippets.json @@ -0,0 +1,120 @@ +{ + "results": [ + { + "id": "1295019", + "packageId": "724687", + "purl": "pkg:cocoapods/Alamofire@5.11.0", + "locator": "pod+Alamofire$5.11.0", + "package": "Alamofire", + "version": "5.11.0", + "kind": "file", + "highestMatchPercentage": 1, + "homeUrl": "https://github.com/alamofire/alamofire", + "codeUrl": "https://github.com/alamofire/alamofire", + "labels": [], + "rejectionDetails": { + "rejectedAt": "2026-06-10T20:13:11.038Z", + "rejectedBy": "sara@fossa.com" + }, + "licenses": [ + { + "signature": "MIT", + "type": "declared" + } + ], + "issueCounts": { + "licensing": { + "denied": 0, + "flagged": 0, + "unknown": 0 + }, + "security": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "unknown": 0 + } + }, + "isVendored": false, + "isConverted": false, + "matchCount": 1 + }, + { + "id": "1295021", + "packageId": "724687", + "purl": "pkg:cocoapods/Alamofire@5.9.1", + "locator": "pod+Alamofire$5.9.1", + "package": "Alamofire", + "version": "5.9.1", + "kind": "file", + "highestMatchPercentage": 1, + "homeUrl": "https://github.com/alamofire/alamofire", + "codeUrl": "https://github.com/alamofire/alamofire", + "labels": [], + "licenses": [ + { + "signature": "MIT", + "type": "declared" + } + ], + "issueCounts": { + "licensing": { + "denied": 0, + "flagged": 0, + "unknown": 0 + }, + "security": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "unknown": 0 + } + }, + "isVendored": true, + "isConverted": true, + "matchCount": 1 + }, + { + "id": "1695309", + "packageId": "724687", + "purl": "pkg:cocoapods/Alamofire@5.11.2", + "locator": "pod+Alamofire$5.11.2", + "package": "Alamofire", + "version": "5.11.2", + "kind": "file", + "highestMatchPercentage": 1, + "releaseDate": "2026-04-06T00:00:00Z", + "homeUrl": "https://github.com/alamofire/alamofire", + "codeUrl": "https://github.com/alamofire/alamofire", + "labels": [], + "licenses": [ + { + "signature": "MIT", + "type": "declared" + } + ], + "issueCounts": { + "licensing": { + "denied": 0, + "flagged": 0, + "unknown": 0 + }, + "security": { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "unknown": 0 + } + }, + "isVendored": false, + "isConverted": false, + "matchCount": 1 + } + ], + "totalCount": 3, + "page": 1, + "pageSize": 10 +} \ No newline at end of file diff --git a/tests/snippet.rs b/tests/snippet.rs new file mode 100644 index 0000000..bd7fe45 --- /dev/null +++ b/tests/snippet.rs @@ -0,0 +1,142 @@ +//! Integration tests for the snippet convenience functions. +//! +//! Uses wiremock to mock the FOSSA API and exercises the public library surface +//! (get_snippets, get_snippet_paths, get_snippet_details, get_snippet_match, +//! get_snippet_locations) against the real response shapes captured from the API. + +use fossapi::FossaClient; +use wiremock::matchers::{method, path, path_regex, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const REV: &str = "custom+1/repo$abc"; +const ENC_REV: &str = "custom%2B1%2Frepo%24abc"; + +const SNIPPETS_JSON: &str = include_str!("fixtures/snippets/snippets.json"); +const PATHS_JSON: &str = include_str!("fixtures/snippets/paths.json"); +const DETAILS_JSON: &str = include_str!("fixtures/snippets/snippet-details.json"); +const MATCH_SMALL_JSON: &str = include_str!("fixtures/snippets/match-details-small.json"); + +fn body(json: &str) -> serde_json::Value { + serde_json::from_str(json).expect("fixture is valid JSON") +} + +#[tokio::test] +async fn get_snippet_paths_returns_tree() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path(format!("/revisions/{ENC_REV}/snippets/paths"))) + .and(query_param("path", "/")) + .respond_with(ResponseTemplate::new(200).set_body_json(body(PATHS_JSON))) + .expect(1) + .mount(&mock_server) + .await; + + let client = FossaClient::new("t", &mock_server.uri()).unwrap(); + let paths = fossapi::get_snippet_paths(&client, REV, Default::default()) + .await + .expect("get_snippet_paths"); + + assert_eq!(paths.len(), 1); + assert_eq!(paths[0].path, "/Sources"); + assert!(!paths[0].is_file()); + assert_eq!(paths[0].count, 3); +} + +#[tokio::test] +async fn get_snippets_returns_results() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path(format!("/revisions/{ENC_REV}/snippets"))) + .respond_with(ResponseTemplate::new(200).set_body_json(body(SNIPPETS_JSON))) + .expect(1) + .mount(&mock_server) + .await; + + let client = FossaClient::new("t", &mock_server.uri()).unwrap(); + let snippets = fossapi::get_snippets(&client, REV, Default::default()) + .await + .expect("get_snippets"); + + assert_eq!(snippets.len(), 3); + assert_eq!(snippets[0].id, "1295019"); + assert_eq!(snippets[0].package, "Alamofire"); + assert_eq!(snippets[0].license_ids(), vec!["MIT"]); +} + +#[tokio::test] +async fn get_snippet_details_populates_matches() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path(format!("/revisions/{ENC_REV}/snippets/1295019"))) + .respond_with(ResponseTemplate::new(200).set_body_json(body(DETAILS_JSON))) + .expect(1) + .mount(&mock_server) + .await; + + let client = FossaClient::new("t", &mock_server.uri()).unwrap(); + let snippet = fossapi::get_snippet_details(&client, REV, "1295019") + .await + .expect("get_snippet_details"); + + assert_eq!(snippet.matches.len(), 1); + assert_eq!(snippet.matches[0].path, "/Sources/Networking/Session.swift"); + assert_eq!(snippet.other_versions.len(), 2); +} + +#[tokio::test] +async fn get_snippet_match_returns_code_with_line_numbers() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path_regex(r"/snippets/1295019/matches/")) + .respond_with(ResponseTemplate::new(200).set_body_json(body(MATCH_SMALL_JSON))) + .expect(1) + .mount(&mock_server) + .await; + + let client = FossaClient::new("t", &mock_server.uri()).unwrap(); + let details = + fossapi::get_snippet_match(&client, REV, "1295019", "/Sources/Networking/Session.swift") + .await + .expect("get_snippet_match"); + + assert_eq!(details.detected_code.len(), 6); + assert_eq!(details.detected_code[0].line_number, 1); + assert!(details.detected_code[0].is_highlighted); +} + +#[tokio::test] +async fn get_snippet_locations_flattens_matches() { + let mock_server = MockServer::start().await; + + // List enumerates the snippets... + Mock::given(method("GET")) + .and(path(format!("/revisions/{ENC_REV}/snippets"))) + .respond_with(ResponseTemplate::new(200).set_body_json(body(SNIPPETS_JSON))) + .mount(&mock_server) + .await; + + // ...then each snippet's details provide its matched files. + Mock::given(method("GET")) + .and(path_regex(r"/snippets/[0-9]+$")) + .respond_with(ResponseTemplate::new(200).set_body_json(body(DETAILS_JSON))) + .mount(&mock_server) + .await; + + let client = FossaClient::new("t", &mock_server.uri()).unwrap(); + let locations = fossapi::get_snippet_locations(&client, REV, Default::default(), false) + .await + .expect("get_snippet_locations"); + + // Three snippets, each with one match in the (shared) details fixture. + assert_eq!(locations.len(), 3); + // snippet_id comes from the list; the ids are distinct. + let ids: Vec<&str> = locations.iter().map(|l| l.snippet_id.as_str()).collect(); + assert_eq!(ids, vec!["1295019", "1295021", "1695309"]); + // Without with_lines, no line range is resolved. + assert!(locations[0].line_start.is_none()); + assert_eq!(locations[0].path, "/Sources/Networking/Session.swift"); +}