use std::sync::{Arc, Mutex};
use std::time::Duration;
use inquire::{InquireError, Select, ui::RenderConfig};
const BOLD: &str = "\x1b[1m";
const BOLD_CYAN: &str = "\x1b[1;36m";
const RESET: &str = "\x1b[0m";
use crate::{
typedetect::{detect_format, FileFormat},
types::TypedValue,
vaultfunc,
};
const SUBNODE_PREFIX: &str = "__subnode__\n";
struct Entry {
display: String,
vault_str: String,
}
struct ClipSession {
clipboard: arboard::Clipboard,
generation: Arc<Mutex<u64>>,
timeout_secs: u64,
password: String,
format: FileFormat,
quit: bool,
color: bool,
}
pub fn clip(
input: &str,
password: &str,
timeout_secs: u64,
color: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if !color {
inquire::set_global_render_config(RenderConfig::empty());
}
let format = detect_format(input)?;
if format == FileFormat::Vault {
return Err("cannot clip from a raw vault-encrypted file (level-0)".into());
}
let content = std::fs::read_to_string(input)
.map_err(|e| format!("error reading '{}': {}", input, e))?;
let mut entries = Vec::new();
match format {
FileFormat::Json => {
let root: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("error parsing JSON from '{}': {}", input, e))?;
collect_json(&root, "", &mut entries);
}
FileFormat::Yaml => {
let root: serde_yaml::Value = serde_yaml::from_str(&content)
.map_err(|e| format!("error parsing YAML from '{}': {}", input, e))?;
collect_yaml(&root, "", &mut entries);
}
FileFormat::Vault => unreachable!(),
}
entries.sort_by(|a, b| a.display.cmp(&b.display));
if entries.is_empty() {
println!("No encrypted values found in '{}'.", input);
return Ok(());
}
let clipboard = arboard::Clipboard::new()
.map_err(|e| format!("failed to open clipboard: {}", e))?;
let mut session = ClipSession {
clipboard,
generation: Arc::new(Mutex::new(0)),
timeout_secs,
password: password.to_string(),
format,
quit: false,
color,
};
session.run(&entries)?;
session.clear();
println!(" Clipboard cleared.");
Ok(())
}
impl ClipSession {
fn run(&mut self, entries: &[Entry]) -> Result<(), Box<dyn std::error::Error>> {
const QUIT: &str = "[ Quit ]";
let mut options: Vec<String> = entries.iter().map(|e| e.display.clone()).collect();
options.insert(0, QUIT.to_string());
loop {
let selected = match Select::new("Select entry (Esc or 'q' to quit):", options.clone()).prompt() {
Ok(s) => s,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => break,
Err(e) => return Err(e.into()),
};
if selected == QUIT {
break;
}
let entry = entries.iter().find(|e| e.display == selected).unwrap();
let vault_str = match entry.vault_str.find("$ANSIBLE_VAULT;") {
Some(idx) => &entry.vault_str[idx..],
None => entry.vault_str.as_str(),
};
let decrypted = match vaultfunc::decrypt(vault_str, &self.password) {
Ok((TypedValue::String(s), _)) => s,
Ok((typed, _)) => typed_to_string(&typed),
Err(e) => {
eprintln!("decryption failed for '{}': {}", selected, e);
continue;
}
};
if let Some(inner) = decrypted.strip_prefix(SUBNODE_PREFIX) {
match self.format {
FileFormat::Json => match serde_json::from_str::<serde_json::Value>(inner) {
Ok(val) => self.field_loop_json(&val)?,
Err(e) => eprintln!("failed to parse sub-node '{}': {}", selected, e),
},
FileFormat::Yaml => match serde_yaml::from_str::<serde_yaml::Value>(inner) {
Ok(val) => self.field_loop_yaml(&val)?,
Err(e) => eprintln!("failed to parse sub-node '{}': {}", selected, e),
},
FileFormat::Vault => unreachable!(),
}
} else {
self.copy(&decrypted, &selected)?;
}
if self.quit {
break;
}
}
Ok(())
}
fn field_loop_json(
&mut self,
val: &serde_json::Value,
) -> Result<(), Box<dyn std::error::Error>> {
const QUIT: &str = "[ Quit ]";
let map = match val.as_object() {
Some(m) => m,
None => return Ok(()),
};
let mut fields: Vec<String> = map.keys().cloned().collect();
if fields.is_empty() {
return Ok(());
}
fields.insert(0, QUIT.to_string());
loop {
let selected = match Select::new("Select field (Esc to go back):", fields.clone()).prompt() {
Ok(f) => f,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Ok(())
}
Err(e) => return Err(e.into()),
};
if selected == QUIT {
self.quit = true;
return Ok(());
}
let child = &val[&selected];
if child.is_object() {
self.field_loop_json(child)?;
} else {
let copy_val = self.json_value_to_string(child);
self.copy(©_val, &selected)?;
}
if self.quit {
return Ok(());
}
}
}
fn field_loop_yaml(
&mut self,
val: &serde_yaml::Value,
) -> Result<(), Box<dyn std::error::Error>> {
const QUIT: &str = "[ Quit ]";
let map = match val.as_mapping() {
Some(m) => m,
None => return Ok(()),
};
let mut fields: Vec<String> = map
.keys()
.filter_map(|k| match k {
serde_yaml::Value::String(s) => Some(s.clone()),
serde_yaml::Value::Number(n) => Some(n.to_string()),
serde_yaml::Value::Bool(b) => Some(b.to_string()),
_ => None,
})
.collect();
if fields.is_empty() {
return Ok(());
}
fields.insert(0, QUIT.to_string());
loop {
let selected = match Select::new("Select field (Esc to go back):", fields.clone()).prompt() {
Ok(f) => f,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Ok(())
}
Err(e) => return Err(e.into()),
};
if selected == QUIT {
self.quit = true;
return Ok(());
}
let child = map.iter().find(|(k, _)| match k {
serde_yaml::Value::String(s) => s == &selected,
serde_yaml::Value::Number(n) => n.to_string() == selected,
serde_yaml::Value::Bool(b) => b.to_string() == selected,
_ => false,
});
match child {
None => eprintln!("field '{}' not found", selected),
Some((_, v)) if v.is_mapping() => self.field_loop_yaml(v)?,
Some((_, v)) => {
let copy_val = self.yaml_value_to_string(v);
self.copy(©_val, &selected)?;
}
}
if self.quit {
return Ok(());
}
}
}
fn json_value_to_string(&self, val: &serde_json::Value) -> String {
match val {
serde_json::Value::String(s) => {
if let Some(idx) = s.find("$ANSIBLE_VAULT;") {
if let Ok((typed, _)) = vaultfunc::decrypt(&s[idx..], &self.password) {
return typed_to_string(&typed);
}
}
s.clone()
}
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Null => String::new(),
other => serde_json::to_string(other).unwrap_or_default(),
}
}
fn yaml_value_to_string(&self, val: &serde_yaml::Value) -> String {
match val {
serde_yaml::Value::String(s) => {
if let Some(idx) = s.find("$ANSIBLE_VAULT;") {
if let Ok((typed, _)) = vaultfunc::decrypt(&s[idx..], &self.password) {
return typed_to_string(&typed);
}
}
s.clone()
}
serde_yaml::Value::Bool(b) => b.to_string(),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Null => String::new(),
serde_yaml::Value::Tagged(t) => self.yaml_value_to_string(&t.value),
other => serde_yaml::to_string(other).unwrap_or_default(),
}
}
fn copy(&mut self, value: &str, label: &str) -> Result<(), Box<dyn std::error::Error>> {
self.clipboard
.set_text(value)
.map_err(|e| format!("clipboard error: {}", e))?;
if self.timeout_secs > 0 {
let current_gen = {
let mut g = self.generation.lock().unwrap();
*g += 1;
*g
};
let gen_arc = self.generation.clone();
let secs = self.timeout_secs;
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(secs));
let g = gen_arc.lock().unwrap();
if *g == current_gen {
drop(g); if let Ok(mut cb) = arboard::Clipboard::new() {
let _ = cb.set_text("");
std::thread::sleep(Duration::from_millis(150));
}
}
});
let emph = if self.color { BOLD_CYAN } else { BOLD };
println!("\n {emph}✓ Copied '{label}' to clipboard.{RESET} (clears in {secs}s)\n");
} else {
let emph = if self.color { BOLD_CYAN } else { BOLD };
println!("\n {emph}✓ Copied '{label}' to clipboard.{RESET}\n");
}
Ok(())
}
fn clear(&mut self) {
*self.generation.lock().unwrap() = u64::MAX;
let _ = self.clipboard.set_text("");
std::thread::sleep(Duration::from_millis(150));
}
}
fn typed_to_string(typed: &TypedValue) -> String {
match typed {
TypedValue::String(s) => s.clone(),
TypedValue::Integer(i) => i.to_string(),
TypedValue::Bool(b) => b.to_string(),
TypedValue::Number(f) => f.to_string(),
TypedValue::Null => String::new(),
}
}
fn collect_json(val: &serde_json::Value, path: &str, out: &mut Vec<Entry>) {
match val {
serde_json::Value::String(s) if s.contains("$ANSIBLE_VAULT;") => {
out.push(Entry {
display: path.replace('.', "/"),
vault_str: s.clone(),
});
}
serde_json::Value::Object(map) => {
for (key, child) in map {
let new_path = if path.is_empty() {
key.clone()
} else {
format!("{}.{}", path, key)
};
collect_json(child, &new_path, out);
}
}
serde_json::Value::Array(arr) => {
for item in arr {
collect_json(item, path, out);
}
}
_ => {}
}
}
fn collect_yaml(val: &serde_yaml::Value, path: &str, out: &mut Vec<Entry>) {
match val {
serde_yaml::Value::String(s) if s.contains("$ANSIBLE_VAULT;") => {
out.push(Entry {
display: path.replace('.', "/"),
vault_str: s.clone(),
});
}
serde_yaml::Value::Tagged(tagged) => {
if let serde_yaml::Value::String(s) = &tagged.value {
if s.contains("$ANSIBLE_VAULT;") {
out.push(Entry {
display: path.replace('.', "/"),
vault_str: s.clone(),
});
}
}
}
serde_yaml::Value::Mapping(map) => {
for (key, child) in map {
let key_str = match key {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
_ => continue,
};
let new_path = if path.is_empty() {
key_str
} else {
format!("{}.{}", path, key_str)
};
collect_yaml(child, &new_path, out);
}
}
serde_yaml::Value::Sequence(seq) => {
for item in seq {
collect_yaml(item, path, out);
}
}
_ => {}
}
}