use crate::suggest::{AllowlistSuggestion, PathPattern};
use serde::Serialize;
use std::fmt::Write as _;
pub const SUGGESTION_OUTPUT_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SuggestionRenderOptions {
pub max_example_commands: usize,
pub max_path_patterns: usize,
pub include_review_note: bool,
}
impl Default for SuggestionRenderOptions {
fn default() -> Self {
Self {
max_example_commands: 5,
max_path_patterns: 3,
include_review_note: true,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SuggestionJsonOutput {
pub schema_version: u32,
pub suggestions: Vec<SuggestionJsonEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SuggestionJsonEntry {
pub pattern: String,
pub frequency: usize,
pub unique_variants: usize,
pub confidence: String,
pub risk: String,
pub reason: String,
pub reason_description: String,
pub score: f32,
pub example_commands: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub path_patterns: Vec<SuggestionPathJson>,
pub suggest_path_specific: bool,
pub bypass_count: usize,
pub suggested_config: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SuggestionPathJson {
pub pattern: String,
pub occurrence_count: usize,
pub is_project_dir: bool,
}
#[must_use]
pub fn render_suggestions_text(
suggestions: &[AllowlistSuggestion],
options: SuggestionRenderOptions,
) -> String {
if suggestions.is_empty() {
return "No allowlist suggestions available.\n".to_string();
}
let mut output = String::new();
let _ = writeln!(output, "Allowlist Suggestions");
let _ = writeln!(output, "=====================");
let _ = writeln!(output);
for (index, suggestion) in suggestions.iter().enumerate() {
let ordinal = index + 1;
let total = suggestions.len();
let _ = writeln!(
output,
"[{ordinal}/{total}] {}",
suggestion.cluster.proposed_pattern
);
let _ = writeln!(output, "----------------------------------------");
let _ = writeln!(
output,
"Blocked: {} times ({} unique variants)",
suggestion.cluster.frequency, suggestion.cluster.unique_count
);
let _ = writeln!(
output,
"Confidence: {} | Risk: {} | Score: {:.2}",
suggestion.confidence, suggestion.risk, suggestion.score
);
let _ = writeln!(output, "Reason: {}", suggestion.reason.description());
if suggestion.bypass_count > 0 {
let _ = writeln!(output, "Bypassed: {} times", suggestion.bypass_count);
}
if !suggestion.path_patterns.is_empty() {
let _ = writeln!(output, "Common paths:");
for path in suggestion
.path_patterns
.iter()
.take(options.max_path_patterns)
{
let marker = if path.is_project_dir {
", project dir"
} else {
""
};
let _ = writeln!(
output,
" - {} ({} occurrences{marker})",
path.pattern, path.occurrence_count
);
}
if suggestion.path_patterns.len() > options.max_path_patterns {
let remaining = suggestion.path_patterns.len() - options.max_path_patterns;
let _ = writeln!(output, " ... and {remaining} more path pattern(s)");
}
}
let _ = writeln!(output, "Example commands:");
for command in suggestion
.cluster
.commands
.iter()
.take(options.max_example_commands)
{
let _ = writeln!(output, " - {command}");
}
if suggestion.cluster.commands.len() > options.max_example_commands {
let remaining = suggestion.cluster.commands.len() - options.max_example_commands;
let _ = writeln!(output, " ... and {remaining} more command(s)");
}
let _ = writeln!(output, "Suggested config:");
output.push_str(&suggested_config_snippet(suggestion));
let _ = writeln!(output);
}
if options.include_review_note {
let _ = writeln!(
output,
"Review suggestions before applying them; regex allowlist entries require risk acknowledgement."
);
}
output
}
#[must_use]
pub fn suggestions_to_json_output(suggestions: &[AllowlistSuggestion]) -> SuggestionJsonOutput {
SuggestionJsonOutput {
schema_version: SUGGESTION_OUTPUT_SCHEMA_VERSION,
suggestions: suggestions
.iter()
.map(|suggestion| SuggestionJsonEntry {
pattern: suggestion.cluster.proposed_pattern.clone(),
frequency: suggestion.cluster.frequency,
unique_variants: suggestion.cluster.unique_count,
confidence: suggestion.confidence.as_str().to_string(),
risk: suggestion.risk.as_str().to_string(),
reason: suggestion.reason.as_str().to_string(),
reason_description: suggestion.reason.description().to_string(),
score: suggestion.score,
example_commands: suggestion.cluster.commands.clone(),
path_patterns: suggestion
.path_patterns
.iter()
.map(path_pattern_to_json)
.collect(),
suggest_path_specific: suggestion.suggest_path_specific,
bypass_count: suggestion.bypass_count,
suggested_config: suggested_config_snippet(suggestion),
})
.collect(),
}
}
pub fn render_suggestions_json(
suggestions: &[AllowlistSuggestion],
) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(&suggestions_to_json_output(suggestions))
}
#[must_use]
pub fn suggested_config_snippet(suggestion: &AllowlistSuggestion) -> String {
let mut output = String::new();
let _ = writeln!(output, "[[allow]]");
let _ = writeln!(
output,
"pattern = \"{}\"",
toml_basic_string(&suggestion.cluster.proposed_pattern)
);
let _ = writeln!(
output,
"reason = \"Auto-suggested ({} confidence, {} risk): {}\"",
suggestion.confidence.as_str(),
suggestion.risk.as_str(),
toml_basic_string(suggestion.reason.description())
);
let _ = writeln!(output, "risk_acknowledged = true");
if suggestion.suggest_path_specific && !suggestion.path_patterns.is_empty() {
let paths = suggestion
.path_patterns
.iter()
.map(|path| format!("\"{}\"", toml_basic_string(&path.pattern)))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(output, "paths = [{paths}]");
}
output
}
fn path_pattern_to_json(path: &PathPattern) -> SuggestionPathJson {
SuggestionPathJson {
pattern: path.pattern.clone(),
occurrence_count: path.occurrence_count,
is_project_dir: path.is_project_dir,
}
}
fn toml_basic_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::suggest::{
AllowlistSuggestion, CommandCluster, ConfidenceTier, PathPattern, RiskLevel,
SuggestionReason,
};
fn make_suggestion() -> AllowlistSuggestion {
AllowlistSuggestion {
cluster: CommandCluster {
commands: vec![
"npm run build:dev".to_string(),
"npm run build:prod".to_string(),
"npm run build:stage".to_string(),
],
normalized: vec![
"npm run build:dev".to_string(),
"npm run build:prod".to_string(),
"npm run build:stage".to_string(),
],
proposed_pattern: "^npm\\s+run\\s+build:(dev|prod|stage)$".to_string(),
frequency: 12,
unique_count: 3,
},
confidence: ConfidenceTier::High,
risk: RiskLevel::Low,
reason: SuggestionReason::PathClustered,
contributing_factors: vec![SuggestionReason::HighFrequency],
path_patterns: vec![
PathPattern {
pattern: "/home/user/projects/*".to_string(),
occurrence_count: 10,
is_project_dir: true,
},
PathPattern {
pattern: "/home/user/tmp".to_string(),
occurrence_count: 2,
is_project_dir: false,
},
],
suggest_path_specific: true,
bypass_count: 2,
safety: Default::default(),
score: 0.92,
}
}
#[test]
fn text_renderer_has_empty_state() {
let output = render_suggestions_text(&[], SuggestionRenderOptions::default());
assert_eq!(output, "No allowlist suggestions available.\n");
}
#[test]
fn text_renderer_includes_core_suggestion_details() {
let output = render_suggestions_text(
&[make_suggestion()],
SuggestionRenderOptions {
max_example_commands: 2,
max_path_patterns: 1,
include_review_note: true,
},
);
assert!(output.contains("Allowlist Suggestions"));
assert!(output.contains("^npm\\s+run\\s+build:(dev|prod|stage)$"));
assert!(output.contains("Blocked: 12 times (3 unique variants)"));
assert!(output.contains("Confidence: high | Risk: low | Score: 0.92"));
assert!(output.contains("Reason: Consistently blocked in specific directories"));
assert!(output.contains("Bypassed: 2 times"));
assert!(output.contains("/home/user/projects/* (10 occurrences, project dir)"));
assert!(output.contains("... and 1 more path pattern(s)"));
assert!(output.contains("... and 1 more command(s)"));
assert!(output.contains("risk_acknowledged = true"));
assert!(output.contains("Review suggestions before applying"));
}
#[test]
fn suggested_config_escapes_toml_strings() {
let mut suggestion = make_suggestion();
suggestion.cluster.proposed_pattern = "^echo \"quoted\" \\\\ path$".to_string();
let snippet = suggested_config_snippet(&suggestion);
assert!(snippet.contains(r#"pattern = "^echo \"quoted\" \\\\ path$""#));
assert!(snippet.contains(r#"paths = ["/home/user/projects/*", "/home/user/tmp"]"#));
}
#[test]
fn json_renderer_uses_stable_shape() {
let output = render_suggestions_json(&[make_suggestion()]).unwrap();
let value: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(value["schema_version"], SUGGESTION_OUTPUT_SCHEMA_VERSION);
assert_eq!(value["suggestions"][0]["frequency"], 12);
assert_eq!(value["suggestions"][0]["unique_variants"], 3);
assert_eq!(value["suggestions"][0]["confidence"], "high");
assert_eq!(value["suggestions"][0]["risk"], "low");
assert_eq!(value["suggestions"][0]["reason"], "path_clustered");
assert_eq!(value["suggestions"][0]["bypass_count"], 2);
assert_eq!(
value["suggestions"][0]["path_patterns"][0]["pattern"],
"/home/user/projects/*"
);
assert!(
value["suggestions"][0]["suggested_config"]
.as_str()
.unwrap()
.contains("[[allow]]")
);
}
}