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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions crates/sprout-cli/src/commands/emoji.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::io::Read;

use crate::client::{normalize_write_response, SproutClient};
use crate::error::CliError;
use sprout_sdk::CustomEmoji;
Expand Down Expand Up @@ -139,6 +141,159 @@ async fn cmd_rm(client: &SproutClient, shortcode: &str) -> Result<(), CliError>
publish_own_set(client, &emojis).await
}

/// 10 MiB — a safety rail against runaway producers. An emoji manifest will
/// never approach this size in practice.
const STDIN_MAX_BYTES: u64 = 10_000_000;

/// Read from a file path or stdin. Returns `CliError::Usage` on empty stdin,
/// `CliError::Other` on I/O failure.
fn read_source(file: Option<&str>) -> Result<String, CliError> {
match file {
Some(path) => std::fs::read_to_string(path)
.map_err(|e| CliError::Other(format!("failed to read file '{path}': {e}"))),
None => {
let mut buf = String::new();
std::io::stdin()
.take(STDIN_MAX_BYTES)
.read_to_string(&mut buf)
.map_err(|e| CliError::Other(format!("stdin read failed: {e}")))?;
if buf.is_empty() {
return Err(CliError::Usage(
"no input: provide --file or pipe JSON to stdin".into(),
));
}
Ok(buf)
}
}
}

/// Write to a file path or stdout.
fn write_output(output: &str, file: Option<&str>) -> Result<(), CliError> {
match file {
Some(path) => std::fs::write(path, output)
.map_err(|e| CliError::Other(format!("failed to write file '{path}': {e}"))),
None => {
println!("{output}");
Ok(())
}
}
}

/// Export custom emojis to stdout or a file.
async fn cmd_export(
client: &SproutClient,
file: Option<&str>,
scope: &crate::EmojiScope,
) -> Result<(), CliError> {
let entries: Vec<EmojiEntry> = match scope {
crate::EmojiScope::Own => {
let mut entries: Vec<EmojiEntry> = fetch_own_emoji(client)
.await?
.into_iter()
.map(|e| EmojiEntry {
shortcode: e.shortcode,
url: e.url,
})
.collect();
// Sort to match union_custom_emoji output order so repeated
// export | import --replace cycles are stable.
entries.sort_by(|a, b| a.shortcode.cmp(&b.shortcode).then(a.url.cmp(&b.url)));
entries
}
crate::EmojiScope::Workspace => {
let filter = serde_json::json!({
"kinds": [sprout_sdk::kind::KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
union_custom_emoji(&events)
}
};
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
write_output(&output, file)
}

/// Import custom emojis from stdin or a file into the caller's own set.
async fn cmd_import(
client: &SproutClient,
file: Option<&str>,
replace: bool,
dry_run: bool,
) -> Result<(), CliError> {
// 1. Read raw JSON
let raw = read_source(file)?;

// 2. Parse and extract ["emojis"] array
let parsed: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| CliError::Usage(format!("invalid JSON: {e}")))?;
let arr = parsed
.get("emojis")
.and_then(|v| v.as_array())
.ok_or_else(|| {
CliError::Usage("input must be a JSON object with an \"emojis\" array".into())
})?;

// 3–4. Parse each element and normalize shortcodes
let mut import_entries: Vec<CustomEmoji> = Vec::with_capacity(arr.len());
for (i, item) in arr.iter().enumerate() {
let shortcode = item
.get("shortcode")
.and_then(|v| v.as_str())
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"shortcode\" field")))?;
let url = item
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"url\" field")))?;
let normalized = sprout_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|e| CliError::Usage(format!("emojis[{i}]: invalid shortcode: {e}")))?;
import_entries.push(CustomEmoji {
shortcode: normalized,
url: url.to_string(),
});
}

// 5. Deduplicate within the import batch (first occurrence wins)
let mut seen = std::collections::HashSet::new();
import_entries.retain(|e| seen.insert(e.shortcode.clone()));

// 6. Build final set
let final_set: Vec<CustomEmoji> = if replace {
import_entries
} else {
let mut existing = fetch_own_emoji(client).await?;
let existing_shortcodes: std::collections::HashSet<String> =
existing.iter().map(|e| e.shortcode.clone()).collect();
for entry in import_entries {
if !existing_shortcodes.contains(&entry.shortcode) {
existing.push(entry);
}
}
existing
};

// 7. Dry-run: print final set to stdout, warn to stderr
if dry_run {
let entries: Vec<EmojiEntry> = final_set
.iter()
.map(|e| EmojiEntry {
shortcode: e.shortcode.clone(),
url: e.url.clone(),
})
.collect();
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
println!("{output}");
eprintln!("(dry run — not published)");
return Ok(());
}

// 8. Publish
publish_own_set(client, &final_set).await
}

// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
Expand All @@ -149,6 +304,12 @@ pub async fn dispatch(cmd: crate::EmojiCmd, client: &SproutClient) -> Result<(),
EmojiCmd::List => cmd_list(client).await,
EmojiCmd::Set { shortcode, url } => cmd_set(client, &shortcode, &url).await,
EmojiCmd::Rm { shortcode } => cmd_rm(client, &shortcode).await,
EmojiCmd::Export { file, scope } => cmd_export(client, file.as_deref(), &scope).await,
EmojiCmd::Import {
file,
replace,
dry_run,
} => cmd_import(client, file.as_deref(), replace, dry_run).await,
}
}

Expand Down
36 changes: 34 additions & 2 deletions crates/sprout-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ pub enum PresenceStatus {
Offline,
}

#[derive(Clone, clap::ValueEnum)]
pub enum EmojiScope {
#[value(name = "own")]
Own,
#[value(name = "workspace")]
Workspace,
}

impl std::fmt::Display for PresenceStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Expand Down Expand Up @@ -599,6 +607,27 @@ pub enum EmojiCmd {
#[arg(long)]
shortcode: String,
},
/// Export custom emojis to stdout or a file
Export {
/// Write JSON to this file path instead of stdout
#[arg(long)]
file: Option<String>,
/// Export your own set (default) or the full workspace palette
#[arg(long, value_enum, default_value = "own")]
scope: EmojiScope,
},
/// Import custom emojis from stdin or a file into your own set
Import {
/// Read JSON from this file path instead of stdin
#[arg(long)]
file: Option<String>,
/// Replace your entire set instead of merging
#[arg(long, default_value_t = false)]
replace: bool,
/// Print what would be published without writing
#[arg(long, default_value_t = false)]
dry_run: bool,
},
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1286,7 +1315,10 @@ mod tests {
);
assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
assert_eq!(names(&cmd, "emoji"), vec!["list", "rm", "set"]);
assert_eq!(
names(&cmd, "emoji"),
vec!["export", "import", "list", "rm", "set"]
);
assert_eq!(
names(&cmd, "dms"),
vec!["add-member", "hide", "list", "open"]
Expand Down Expand Up @@ -1323,7 +1355,7 @@ mod tests {
("canvas", 2),
("channels", 16),
("dms", 4),
("emoji", 3),
("emoji", 5),
("feed", 1),
("messages", 8),
("pack", 2),
Expand Down