#![allow(clippy::missing_errors_doc)]
use anyhow::{Context, Result, bail};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use crate::api::RedashClient;
use crate::models::{CreateQuerySnippet, QuerySnippet, SnippetMetadata};
fn find_snippet_files_in(snippets_dir: &Path, snippet_id: u64) -> Result<Option<(String, String)>> {
if !snippets_dir.exists() {
return Ok(None);
}
let mut sql_path = None;
let mut yaml_path = None;
for entry in fs::read_dir(snippets_dir).context("Failed to read snippets directory")? {
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|f| f.to_str())
&& let Some(id_str) = filename.split('-').next()
&& let Ok(id) = id_str.parse::<u64>()
&& id == snippet_id
{
if path.extension().is_some_and(|ext| ext == "sql") {
sql_path = Some(path.to_string_lossy().to_string());
} else if path.extension().is_some_and(|ext| ext == "yaml") {
yaml_path = Some(path.to_string_lossy().to_string());
}
}
}
match (sql_path, yaml_path) {
(Some(sql), Some(yaml)) => Ok(Some((sql, yaml))),
_ => Ok(None),
}
}
fn find_snippet_files(snippet_id: u64) -> Result<Option<(String, String)>> {
find_snippet_files_in(Path::new("snippets"), snippet_id)
}
fn extract_snippet_ids_from_path(snippets_dir: &Path) -> Result<Vec<u64>> {
if !snippets_dir.exists() {
return Ok(Vec::new());
}
let mut snippet_ids = Vec::new();
for entry in fs::read_dir(snippets_dir).context("Failed to read snippets directory")? {
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "yaml")
&& let Some(filename) = path.file_name().and_then(|f| f.to_str())
&& let Some(id_str) = filename.split('-').next()
&& let Ok(id) = id_str.parse::<u64>()
{
snippet_ids.push(id);
}
}
snippet_ids.sort_unstable();
snippet_ids.dedup();
Ok(snippet_ids)
}
fn extract_snippet_ids_from_directory() -> Result<Vec<u64>> {
extract_snippet_ids_from_path(Path::new("snippets"))
}
fn bail_on_duplicate_ids(paths_by_id: &HashMap<u64, Vec<String>>) -> Result<()> {
let mut conflicts: Vec<_> = paths_by_id
.iter()
.filter(|(_, paths)| paths.len() > 1)
.collect();
if conflicts.is_empty() {
return Ok(());
}
conflicts.sort_by_key(|(id, _)| **id);
let details: Vec<String> = conflicts
.into_iter()
.map(|(id, paths)| {
let mut paths = paths.clone();
paths.sort();
format!(" id {id}: {}", paths.join(", "))
})
.collect();
bail!(
"Multiple local files claim the same id — resolve the conflict before deploying:\n{}",
details.join("\n")
);
}
fn get_all_snippet_metadata_from_path(snippets_dir: &Path) -> Result<Vec<(u64, String)>> {
if !snippets_dir.exists() {
bail!("snippets directory not found. Run 'stmo-cli snippets fetch' first.");
}
let mut snippets = Vec::new();
let mut paths_by_id: HashMap<u64, Vec<String>> = HashMap::new();
for entry in fs::read_dir(snippets_dir).context("Failed to read snippets directory")? {
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "yaml") {
let metadata_content =
fs::read_to_string(&path).context(format!("Failed to read {}", path.display()))?;
let metadata: SnippetMetadata = serde_yaml::from_str(&metadata_content)
.context(format!("Failed to parse {}", path.display()))?;
paths_by_id
.entry(metadata.id)
.or_default()
.push(path.display().to_string());
snippets.push((metadata.id, metadata.trigger));
}
}
bail_on_duplicate_ids(&paths_by_id)?;
snippets.sort_by_key(|(id, _)| *id);
Ok(snippets)
}
fn get_all_snippet_metadata() -> Result<Vec<(u64, String)>> {
get_all_snippet_metadata_from_path(Path::new("snippets"))
}
fn snippet_differs(
local_body: &str,
local_metadata: &SnippetMetadata,
server: &QuerySnippet,
) -> bool {
local_body != server.snippet
|| local_metadata.trigger != server.trigger
|| local_metadata.description != server.description
}
fn read_local_snippet(id: u64, trigger: &str) -> Result<(String, SnippetMetadata)> {
let sql_path = format!("snippets/{id}-{trigger}.sql");
let yaml_path = format!("snippets/{id}-{trigger}.yaml");
let body = fs::read_to_string(&sql_path).context(format!("Failed to read {sql_path}"))?;
let metadata_content =
fs::read_to_string(&yaml_path).context(format!("Failed to read {yaml_path}"))?;
let metadata: SnippetMetadata =
serde_yaml::from_str(&metadata_content).context(format!("Failed to parse {yaml_path}"))?;
Ok((body, metadata))
}
async fn find_changed_snippets(
client: &RedashClient,
all_snippets: &[(u64, String)],
) -> Result<HashSet<u64>> {
let server_snippets = client.list_query_snippets().await?;
let mut changed_ids = HashSet::new();
for (id, trigger) in all_snippets {
let id = *id;
if id == 0 {
changed_ids.insert(id);
continue;
}
let Some(server) = server_snippets.iter().find(|s| s.id == id) else {
eprintln!(" âš Skipping snippet {id}: not found on the server");
continue;
};
match read_local_snippet(id, trigger) {
Ok((body, metadata)) => {
if snippet_differs(&body, &metadata, server) {
changed_ids.insert(id);
}
}
Err(e) => eprintln!(" âš Skipping snippet {id}: {e}"),
}
}
Ok(changed_ids)
}
fn write_snippet_files(snippet: &QuerySnippet) -> Result<()> {
fs::create_dir_all("snippets").context("Failed to create snippets directory")?;
let filename_base = format!("{}-{}", snippet.id, snippet.trigger);
let sql_path = format!("snippets/{filename_base}.sql");
fs::write(&sql_path, &snippet.snippet).context(format!("Failed to write {sql_path}"))?;
let metadata = SnippetMetadata {
id: snippet.id,
trigger: snippet.trigger.clone(),
description: snippet.description.clone(),
};
let yaml_path = format!("snippets/{filename_base}.yaml");
let yaml_content =
serde_yaml::to_string(&metadata).context("Failed to serialize snippet metadata")?;
fs::write(&yaml_path, yaml_content).context(format!("Failed to write {yaml_path}"))?;
Ok(())
}
fn delete_snippet_files(sql_path: &str, yaml_path: &str) -> Result<()> {
fs::remove_file(sql_path).context(format!("Failed to delete {sql_path}"))?;
fs::remove_file(yaml_path).context(format!("Failed to delete {yaml_path}"))?;
Ok(())
}
pub async fn list(client: &RedashClient) -> Result<()> {
let mut snippets = client.list_query_snippets().await?;
snippets.sort_by_key(|s| s.id);
println!("=== QUERY SNIPPETS ({}) ===\n", snippets.len());
for snippet in &snippets {
let desc = snippet.description.as_deref().unwrap_or("");
println!(" {} - {}", snippet.id, snippet.trigger);
if !desc.is_empty() {
println!(" {desc}");
}
}
Ok(())
}
pub async fn fetch(client: &RedashClient, snippet_ids: Vec<u64>, all: bool) -> Result<()> {
fs::create_dir_all("snippets").context("Failed to create snippets directory")?;
let snippets_to_fetch = if all {
let existing_ids = extract_snippet_ids_from_directory()?;
if existing_ids.is_empty() {
bail!(
"No snippets found in snippets/ directory. Use specific snippet IDs or run 'snippets list' to see available snippets."
);
}
println!(
"Fetching {} snippets from local directory...\n",
existing_ids.len()
);
let mut fetched = Vec::new();
for id in &existing_ids {
match client.get_query_snippet(*id).await {
Ok(snippet) => fetched.push(snippet),
Err(e) => eprintln!(" âš Snippet {id} failed to fetch: {e}"),
}
}
fetched
} else if !snippet_ids.is_empty() {
println!("Fetching {} specific snippets...\n", snippet_ids.len());
let mut fetched = Vec::new();
for id in &snippet_ids {
match client.get_query_snippet(*id).await {
Ok(snippet) => fetched.push(snippet),
Err(e) => eprintln!(" âš Snippet {id} failed to fetch: {e}"),
}
}
fetched
} else {
bail!(
"No snippet IDs specified. Use --all to fetch tracked snippets, or provide specific snippet IDs.\n\nExamples:\n stmo-cli snippets fetch --all\n stmo-cli snippets fetch 31\n stmo-cli snippets list (to see available snippets)"
);
};
println!("Fetching {} snippets...", snippets_to_fetch.len());
for snippet in &snippets_to_fetch {
write_snippet_files(snippet)?;
println!(" ✓ {} - {}", snippet.id, snippet.trigger);
}
println!("\n✓ All snippets fetched successfully");
Ok(())
}
pub async fn deploy_one(client: &RedashClient, id: u64, trigger: &str) -> Result<QuerySnippet> {
let sql_path = format!("snippets/{id}-{trigger}.sql");
let yaml_path = format!("snippets/{id}-{trigger}.yaml");
if !Path::new(&sql_path).exists() {
bail!("Snippet SQL file not found: {sql_path}");
}
if !Path::new(&yaml_path).exists() {
bail!("Snippet metadata file not found: {yaml_path}");
}
let body = fs::read_to_string(&sql_path).context(format!("Failed to read {sql_path}"))?;
let metadata_content =
fs::read_to_string(&yaml_path).context(format!("Failed to read {yaml_path}"))?;
let metadata: SnippetMetadata =
serde_yaml::from_str(&metadata_content).context(format!("Failed to parse {yaml_path}"))?;
let result = if id == 0 {
let create = CreateQuerySnippet {
trigger: metadata.trigger.clone(),
description: metadata.description.clone(),
snippet: body,
};
let created = client.create_query_snippet(&create).await?;
write_snippet_files(&created)?;
fs::remove_file(&sql_path).context(format!("Failed to delete {sql_path}"))?;
fs::remove_file(&yaml_path).context(format!("Failed to delete {yaml_path}"))?;
println!(
" ✓ Created new snippet: {} - {}",
created.id, created.trigger
);
println!(
" Renamed: 0-{trigger}.* → {}-{}.*",
created.id, created.trigger
);
created
} else {
let snippet = QuerySnippet {
id,
trigger: metadata.trigger.clone(),
description: metadata.description.clone(),
snippet: body,
user: None,
updated_at: String::new(),
created_at: String::new(),
};
let updated = client.update_query_snippet(&snippet).await?;
write_snippet_files(&updated)?;
println!(" ✓ {id} - {}", updated.trigger);
updated
};
Ok(result)
}
pub async fn deploy(client: &RedashClient, snippet_ids: Vec<u64>, all: bool) -> Result<()> {
let all_snippets = get_all_snippet_metadata()?;
let snippets_to_deploy = if !snippet_ids.is_empty() {
let ids_set: HashSet<_> = snippet_ids.iter().copied().collect();
let filtered: Vec<_> = all_snippets
.into_iter()
.filter(|(id, _)| ids_set.contains(id))
.collect();
if filtered.is_empty() {
bail!("None of the specified snippet IDs were found in snippets/ directory");
}
println!("Deploying {} specific snippets...", filtered.len());
for (id, trigger) in &filtered {
println!(" → {id} - {trigger}");
}
println!();
filtered
} else if all {
println!("Deploying all {} snippets...\n", all_snippets.len());
all_snippets
} else {
let changed_ids = find_changed_snippets(client, &all_snippets).await?;
if changed_ids.is_empty() {
println!("No changed snippets detected.");
println!("Tip: Use --all to deploy all snippets regardless of differences.");
return Ok(());
}
let filtered: Vec<_> = all_snippets
.into_iter()
.filter(|(id, _)| changed_ids.contains(id))
.collect();
println!("Deploying {} changed snippets...", filtered.len());
for (id, trigger) in &filtered {
println!(" → {id} - {trigger}");
}
println!();
filtered
};
for (id, trigger) in &snippets_to_deploy {
deploy_one(client, *id, trigger).await?;
}
println!("\n✓ All snippets deployed successfully");
Ok(())
}
pub async fn delete(client: &RedashClient, snippet_ids: Vec<u64>) -> Result<()> {
let mut errors = Vec::new();
let mut deleted_count = 0;
println!("Deleting {} query snippets...\n", snippet_ids.len());
for snippet_id in &snippet_ids {
match client.delete_query_snippet(*snippet_id).await {
Ok(()) => {
println!(" ✓ Deleted snippet {snippet_id}");
if let Ok(Some((sql_path, yaml_path))) = find_snippet_files(*snippet_id) {
if let Err(e) = delete_snippet_files(&sql_path, &yaml_path) {
eprintln!(" âš Failed to delete local files for snippet {snippet_id}: {e}");
} else {
println!(" Deleted local files");
}
} else {
println!(" No local files found");
}
deleted_count += 1;
}
Err(e) => {
eprintln!(" ✗ Failed to delete snippet {snippet_id}: {e}");
errors.push((*snippet_id, e));
}
}
}
println!(
"\n✓ Deleted {deleted_count}/{} query snippets",
snippet_ids.len()
);
if !errors.is_empty() {
anyhow::bail!("Failed to delete {} query snippets", errors.len());
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::missing_errors_doc)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_extract_snippet_ids_from_path_empty() {
let temp_dir = TempDir::new().unwrap();
let result = extract_snippet_ids_from_path(temp_dir.path());
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_extract_snippet_ids_from_path_missing_directory() {
let temp_dir = TempDir::new().unwrap();
let missing = temp_dir.path().join("does-not-exist");
let result = extract_snippet_ids_from_path(&missing);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_extract_snippet_ids_from_path_deduplication() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("31-old_trigger_name.yaml"), "test").unwrap();
fs::write(dir.join("31-new_trigger_name.yaml"), "test").unwrap();
let ids = extract_snippet_ids_from_path(dir).unwrap();
assert_eq!(ids, vec![31]);
}
#[test]
fn test_extract_snippet_ids_from_path_ignores_non_yaml() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "test").unwrap();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "test").unwrap();
fs::write(dir.join("README.md"), "test").unwrap();
let ids = extract_snippet_ids_from_path(dir).unwrap();
assert_eq!(ids, vec![31]);
}
#[test]
fn test_extract_snippet_ids_from_path_ignores_id_without_separator() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("31.yaml"), "test").unwrap();
fs::write(dir.join("42-zebra.yaml"), "test").unwrap();
let ids = extract_snippet_ids_from_path(dir).unwrap();
assert_eq!(ids, vec![42]);
}
#[test]
fn test_extract_snippet_ids_from_path_includes_id_zero() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("0-stmo_cli_selftest.yaml"), "test").unwrap();
let ids = extract_snippet_ids_from_path(dir).unwrap();
assert_eq!(ids, vec![0]);
}
#[test]
fn test_extract_snippet_ids_from_path_sorted() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("42-zebra.yaml"), "test").unwrap();
fs::write(dir.join("9-hll_convert.yaml"), "test").unwrap();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "test").unwrap();
let ids = extract_snippet_ids_from_path(dir).unwrap();
assert_eq!(ids, vec![9, 31, 42]);
}
#[test]
fn test_find_snippet_files_in_found() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
let result = find_snippet_files_in(dir, 31).unwrap();
assert!(result.is_some());
let (sql_path, yaml_path) = result.unwrap();
assert_eq!(
Path::new(&sql_path).extension(),
Some(std::ffi::OsStr::new("sql"))
);
assert_eq!(
Path::new(&yaml_path).extension(),
Some(std::ffi::OsStr::new("yaml"))
);
}
#[test]
fn test_find_snippet_files_in_no_matching_id() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
let result = find_snippet_files_in(dir, 99).unwrap();
assert!(result.is_none());
}
#[test]
fn test_find_snippet_files_in_missing_yaml() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
let result = find_snippet_files_in(dir, 31).unwrap();
assert!(result.is_none());
}
#[test]
fn test_find_snippet_files_in_missing_directory() {
let temp_dir = TempDir::new().unwrap();
let missing = temp_dir.path().join("does-not-exist");
let result = find_snippet_files_in(&missing, 31).unwrap();
assert!(result.is_none());
}
#[test]
fn test_find_snippet_files_in_id_zero() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("0-stmo_cli_selftest.sql"), "SELECT 1").unwrap();
fs::write(dir.join("0-stmo_cli_selftest.yaml"), "id: 0").unwrap();
let result = find_snippet_files_in(dir, 0).unwrap();
assert!(result.is_some());
}
#[test]
fn test_find_snippet_files_in_missing_sql() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
let result = find_snippet_files_in(dir, 31).unwrap();
assert!(result.is_none());
}
#[test]
fn test_find_snippet_files_in_exact_id_match_not_prefix() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
let result = find_snippet_files_in(dir, 3).unwrap();
assert!(result.is_none());
}
#[test]
fn test_get_all_snippet_metadata_from_path_basic() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(
dir.join("31-reviewbot_e2e_action_ctcs.yaml"),
"id: 31\ntrigger: reviewbot_e2e_action_ctcs\ndescription: null\n",
)
.unwrap();
let metadata = get_all_snippet_metadata_from_path(dir).unwrap();
assert_eq!(
metadata,
vec![(31, "reviewbot_e2e_action_ctcs".to_string())]
);
}
#[test]
fn test_get_all_snippet_metadata_from_path_sorted_by_id() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(
dir.join("42-zebra.yaml"),
"id: 42\ntrigger: zebra\ndescription: null\n",
)
.unwrap();
fs::write(
dir.join("9-hll_convert.yaml"),
"id: 9\ntrigger: hll_convert\ndescription: null\n",
)
.unwrap();
let metadata = get_all_snippet_metadata_from_path(dir).unwrap();
assert_eq!(
metadata,
vec![(9, "hll_convert".to_string()), (42, "zebra".to_string())]
);
}
#[test]
fn test_get_all_snippet_metadata_from_path_missing_directory_errors() {
let temp_dir = TempDir::new().unwrap();
let missing = temp_dir.path().join("does-not-exist");
let result = get_all_snippet_metadata_from_path(&missing);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("snippets directory not found")
);
}
#[test]
fn test_get_all_snippet_metadata_from_path_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let metadata = get_all_snippet_metadata_from_path(temp_dir.path()).unwrap();
assert!(metadata.is_empty());
}
#[test]
fn test_get_all_snippet_metadata_from_path_malformed_yaml_errors() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(
dir.join("31-broken.yaml"),
"description: missing required fields\n",
)
.unwrap();
let result = get_all_snippet_metadata_from_path(dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Failed to parse"));
}
#[test]
fn test_get_all_snippet_metadata_from_path_rejects_duplicate_ids() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
fs::write(
dir.join("120506-first-trigger.yaml"),
"id: 120506\ntrigger: first_trigger\ndescription: null\n",
)
.unwrap();
fs::write(
dir.join("120506-second-trigger.yaml"),
"id: 120506\ntrigger: second_trigger\ndescription: null\n",
)
.unwrap();
let result = get_all_snippet_metadata_from_path(dir);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Multiple local files claim the same id"));
assert!(err_msg.contains("id 120506"));
assert!(err_msg.contains("120506-first-trigger.yaml"));
assert!(err_msg.contains("120506-second-trigger.yaml"));
}
#[test]
fn test_snippet_differs_false_when_identical() {
let metadata = SnippetMetadata {
id: 1,
trigger: "t".to_string(),
description: None,
};
let server = make_server_snippet(1, "t", "SELECT 1", None);
assert!(!snippet_differs("SELECT 1", &metadata, &server));
}
#[test]
fn test_snippet_differs_true_when_body_differs() {
let metadata = SnippetMetadata {
id: 1,
trigger: "t".to_string(),
description: None,
};
let server = make_server_snippet(1, "t", "SELECT 1", None);
assert!(snippet_differs("SELECT 2", &metadata, &server));
}
#[test]
fn test_snippet_differs_true_when_trigger_differs() {
let metadata = SnippetMetadata {
id: 1,
trigger: "local_trigger".to_string(),
description: None,
};
let server = make_server_snippet(1, "server_trigger", "SELECT 1", None);
assert!(snippet_differs("SELECT 1", &metadata, &server));
}
#[test]
fn test_snippet_differs_true_when_description_differs() {
let metadata = SnippetMetadata {
id: 1,
trigger: "t".to_string(),
description: Some("local".to_string()),
};
let server = make_server_snippet(1, "t", "SELECT 1", Some("server".to_string()));
assert!(snippet_differs("SELECT 1", &metadata, &server));
}
fn make_server_snippet(
id: u64,
trigger: &str,
snippet: &str,
description: Option<String>,
) -> QuerySnippet {
QuerySnippet {
id,
trigger: trigger.to_string(),
description,
snippet: snippet.to_string(),
user: None,
updated_at: String::new(),
created_at: String::new(),
}
}
}