/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2026 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
use std::{
collections::{HashMap, HashSet},
io::{stdin, Cursor, Read},
slice,
str::FromStr,
};
use rodio::{Decoder, OutputStreamBuilder, Sink};
use smol::channel::Sender;
use structopt_toml::clap::{App, Arg, Shell, SubCommand};
use darkfi::{
cli_desc,
tx::{ContractCallLeaf, Transaction, TransactionBuilder},
util::{encoding::base64, parse::decode_base10},
zk::Proof,
Error, Result,
};
use darkfi_money_contract::model::TokenId;
use darkfi_sdk::{
crypto::{keypair::Address, pasta_prelude::PrimeField, FuncId, SecretKey},
dark_tree::DarkTree,
pasta::pallas,
ContractCallImport,
};
use darkfi_serial::deserialize_async;
use crate::{money::BALANCE_BASE10_DECIMALS, Drk};
/// Auxiliary function to parse a base64 encoded transaction from stdin.
pub async fn parse_tx_from_stdin() -> Result {
let mut buf = String::new();
stdin().read_to_string(&mut buf)?;
match base64::decode(buf.trim()) {
Some(bytes) => Ok(deserialize_async(&bytes).await?),
None => Err(Error::ParseFailed("Failed to decode transaction")),
}
}
/// Auxiliary function to parse a base64 encoded transaction from
/// provided input or fallback to stdin if its empty.
pub async fn parse_tx_from_input(input: &[String]) -> Result {
match input.len() {
0 => parse_tx_from_stdin().await,
1 => match base64::decode(input[0].trim()) {
Some(bytes) => Ok(deserialize_async(&bytes).await?),
None => Err(Error::ParseFailed("Failed to decode transaction")),
},
_ => Err(Error::ParseFailed("Multiline input provided")),
}
}
/// Auxiliary function to parse base64 encoded contract calls from stdin.
pub async fn parse_calls_from_stdin() -> Result> {
let lines = stdin().lines();
let mut calls = vec![];
for line in lines {
let Some(line) = base64::decode(&line?) else {
return Err(Error::ParseFailed("Failed to decode base64"))
};
calls.push(deserialize_async(&line).await?);
}
Ok(calls)
}
/// Auxiliary function to parse base64 encoded contract calls from
/// provided input or fallback to stdin if its empty.
pub async fn parse_calls_from_input(input: &[String]) -> Result> {
if input.is_empty() {
return parse_calls_from_stdin().await
}
let mut calls = vec![];
for line in input {
let Some(line) = base64::decode(line) else {
return Err(Error::ParseFailed("Failed to decode base64"))
};
calls.push(deserialize_async(&line).await?);
}
Ok(calls)
}
/// Auxiliary function to parse provided string into a values pair.
pub fn parse_value_pair(s: &str) -> Result<(u64, u64)> {
let v: Vec<&str> = s.split(':').collect();
if v.len() != 2 {
return Err(Error::ParseFailed("Invalid value pair. Use a pair such as 13.37:11.0"))
}
let val0 = decode_base10(v[0], BALANCE_BASE10_DECIMALS, true);
let val1 = decode_base10(v[1], BALANCE_BASE10_DECIMALS, true);
if val0.is_err() || val1.is_err() {
return Err(Error::ParseFailed("Invalid value pair. Use a pair such as 13.37:11.0"))
}
Ok((val0.unwrap(), val1.unwrap()))
}
/// Auxiliary function to parse provided string into a tokens pair.
pub async fn parse_token_pair(drk: &Drk, s: &str) -> Result<(TokenId, TokenId)> {
let v: Vec<&str> = s.split(':').collect();
if v.len() != 2 {
return Err(Error::ParseFailed(
"Invalid token pair. Use a pair such as:\nWCKD:MLDY\nor\n\
A7f1RKsCUUHrSXA7a9ogmwg8p3bs6F47ggsW826HD4yd:FCuoMii64H5Ee4eVWBjP18WTFS8iLUJmGi16Qti1xFQ2"
))
}
let tok0 = drk.get_token(v[0].to_string()).await;
let tok1 = drk.get_token(v[1].to_string()).await;
if tok0.is_err() || tok1.is_err() {
return Err(Error::ParseFailed(
"Invalid token pair. Use a pair such as:\nWCKD:MLDY\nor\n\
A7f1RKsCUUHrSXA7a9ogmwg8p3bs6F47ggsW826HD4yd:FCuoMii64H5Ee4eVWBjP18WTFS8iLUJmGi16Qti1xFQ2"
))
}
Ok((tok0.unwrap(), tok1.unwrap()))
}
/// Fun police go away
pub async fn kaching() {
const WALLET_MP3: &[u8] = include_bytes!("../wallet.mp3");
let cursor = Cursor::new(WALLET_MP3);
let Ok(stream_handle) = OutputStreamBuilder::open_default_stream() else { return };
let sink = Sink::connect_new(stream_handle.mixer());
let Ok(source) = Decoder::new(cursor) else { return };
sink.append(source);
sink.detach();
}
/// Auxiliary function to generate provided shell completions.
pub fn generate_completions(shell: &str) -> Result {
// Sub-commands
// Interactive
let interactive = SubCommand::with_name("interactive").about("Enter Drk interactive shell");
// Kaching
let kaching = SubCommand::with_name("kaching").about("Fun");
// Ping
let ping =
SubCommand::with_name("ping").about("Send a ping request to the darkfid RPC endpoint");
// Completions
let shell_arg = Arg::with_name("shell").help("The Shell you want to generate script for");
let completions = SubCommand::with_name("completions")
.about("Generate a SHELL completion script and print to stdout")
.arg(shell_arg);
// Wallet
let initialize = SubCommand::with_name("initialize").about("Initialize wallet database");
let keygen = SubCommand::with_name("keygen").about("Generate a new keypair in the wallet");
let balance = SubCommand::with_name("balance").about("Query the wallet for known balances");
let address = SubCommand::with_name("address").about("Get the default address in the wallet");
let addresses =
SubCommand::with_name("addresses").about("Print all the addresses in the wallet");
let index = Arg::with_name("index").help("Identifier of the address");
let default_address = SubCommand::with_name("default-address")
.about("Set the default address in the wallet")
.arg(index.clone());
let secrets =
SubCommand::with_name("secrets").about("Print all the secret keys from the wallet");
let import_secrets = SubCommand::with_name("import-secrets")
.about("Import secret keys from stdin into the wallet, separated by newlines");
let tree = SubCommand::with_name("tree").about("Print the Merkle tree in the wallet");
let coins = SubCommand::with_name("coins").about("Print all the coins in the wallet");
let spend_hook = Arg::with_name("spend-hook").help("Optional contract spend hook to use");
let user_data = Arg::with_name("user-data").help("Optional user data to use");
let mining_config = SubCommand::with_name("mining-config")
.about("Print a wallet address mining configuration")
.args(&[index, spend_hook.clone(), user_data.clone()]);
let wallet = SubCommand::with_name("wallet").about("Wallet operations").subcommands(vec![
initialize,
keygen,
balance,
address,
addresses,
default_address,
secrets,
import_secrets,
tree,
coins,
mining_config,
]);
// Spend
let spend = SubCommand::with_name("spend")
.about("Read a transaction from stdin and mark its input coins as spent");
// Unspend
let coin = Arg::with_name("coin").help("base64-encoded coin to mark as unspent");
let unspend = SubCommand::with_name("unspend").about("Unspend a coin").arg(coin);
// Transfer
let amount = Arg::with_name("amount").help("Amount to send");
let token = Arg::with_name("token").help("Token ID to send");
let recipient = Arg::with_name("recipient").help("Recipient address");
let half_split = Arg::with_name("half-split")
.long("half-split")
.help("Split the output coin into two equal halves");
let transfer = SubCommand::with_name("transfer").about("Create a payment transaction").args(&[
amount.clone(),
token.clone(),
recipient.clone(),
spend_hook.clone(),
user_data.clone(),
half_split,
]);
// Otc
let value_pair = Arg::with_name("value-pair")
.short("v")
.long("value-pair")
.takes_value(true)
.help("Value pair to send:recv (11.55:99.42)");
let token_pair = Arg::with_name("token-pair")
.short("t")
.long("token-pair")
.takes_value(true)
.help("Token pair to send:recv (f00:b4r)");
let init = SubCommand::with_name("init")
.about("Initialize the first half of the atomic swap")
.args(&[value_pair, token_pair]);
let join =
SubCommand::with_name("join").about("Build entire swap tx given the first half from stdin");
let inspect = SubCommand::with_name("inspect")
.about("Inspect a swap half or the full swap tx from stdin");
let sign = SubCommand::with_name("sign").about("Sign a swap transaction given from stdin");
let otc = SubCommand::with_name("otc")
.about("OTC atomic swap")
.subcommands(vec![init, join, inspect, sign]);
// DAO
let proposer_limit = Arg::with_name("proposer-limit")
.help("The minimum amount of governance tokens needed to open a proposal for this DAO");
let quorum = Arg::with_name("quorum")
.help("Minimal threshold of participating total tokens needed for a proposal to pass");
let early_exec_quorum = Arg::with_name("early-exec-quorum")
.help("Minimal threshold of participating total tokens needed for a proposal to be considered as strongly supported, enabling early execution. Must be greater or equal to normal quorum.");
let approval_ratio = Arg::with_name("approval-ratio")
.help("The ratio of winning votes/total votes needed for a proposal to pass (2 decimals)");
let gov_token_id = Arg::with_name("gov-token-id").help("DAO's governance token ID");
let create = SubCommand::with_name("create").about("Create DAO parameters").args(&[
proposer_limit,
quorum,
early_exec_quorum,
approval_ratio,
gov_token_id,
]);
let view = SubCommand::with_name("view").about("View DAO data from stdin");
let name = Arg::with_name("name").help("Name identifier for the DAO");
let import = SubCommand::with_name("import")
.about("Import DAO data from stdin")
.args(slice::from_ref(&name));
let remove = SubCommand::with_name("remove")
.about("Remove a DAO and all its data")
.args(slice::from_ref(&name));
let opt_name = Arg::with_name("dao-alias").help("Name identifier for the DAO (optional)");
let list = SubCommand::with_name("list")
.about("List imported DAOs (or info about a specific one)")
.args(&[opt_name]);
let balance = SubCommand::with_name("balance")
.about("Show the balance of a DAO")
.args(slice::from_ref(&name));
let mint = SubCommand::with_name("mint")
.about("Mint an imported DAO on-chain")
.args(slice::from_ref(&name));
let duration = Arg::with_name("duration").help("Duration of the proposal, in block windows");
let propose_transfer = SubCommand::with_name("propose-transfer")
.about("Create a transfer proposal for a DAO")
.args(&[
name.clone(),
duration.clone(),
amount,
token,
recipient,
spend_hook.clone(),
user_data.clone(),
]);
let propose_generic = SubCommand::with_name("propose-generic")
.about("Create a generic proposal for a DAO")
.args(&[name.clone(), duration, user_data.clone()]);
let proposals = SubCommand::with_name("proposals").about("List DAO proposals").arg(&name);
let bulla = Arg::with_name("bulla").help("Bulla identifier for the proposal");
let export = Arg::with_name("export").help("Encrypt the proposal and encode it to base64");
let mint_proposal = Arg::with_name("mint-proposal").help("Create the proposal transaction");
let proposal = SubCommand::with_name("proposal").about("View a DAO proposal data").args(&[
bulla.clone(),
export,
mint_proposal,
]);
let proposal_import = SubCommand::with_name("proposal-import")
.about("Import a base64 encoded and encrypted proposal from stdin");
let vote = Arg::with_name("vote").help("Vote (0 for NO, 1 for YES)");
let vote_weight =
Arg::with_name("vote-weight").help("Optional vote weight (amount of governance tokens)");
let vote = SubCommand::with_name("vote").about("Vote on a given proposal").args(&[
bulla.clone(),
vote,
vote_weight,
]);
let early = Arg::with_name("early").long("early").help("Execute the proposal early");
let exec = SubCommand::with_name("exec").about("Execute a DAO proposal").args(&[bulla, early]);
let spend_hook_cmd = SubCommand::with_name("spend-hook")
.about("Print the DAO contract base64-encoded spend hook");
let mining_config =
SubCommand::with_name("mining-config").about("Print a DAO mining configuration").arg(name);
let dao = SubCommand::with_name("dao").about("DAO functionalities").subcommands(vec![
create,
view,
import,
remove,
list,
balance,
mint,
propose_transfer,
propose_generic,
proposals,
proposal,
proposal_import,
vote,
exec,
spend_hook_cmd,
mining_config,
]);
// AttachFee
let attach_fee = SubCommand::with_name("attach-fee")
.about("Attach the fee call to a transaction given from stdin");
// TxFromCalls
let calls_map =
Arg::with_name("calls-map").help("Optional parent/children dependency map for the calls");
let tx_from_calls = SubCommand::with_name("tx-from-calls")
.about(
"Create a transaction from newline-separated calls from stdin and attach the fee call",
)
.args(&[calls_map]);
// Inspect
let inspect = SubCommand::with_name("inspect").about("Inspect a transaction from stdin");
// Broadcast
let broadcast =
SubCommand::with_name("broadcast").about("Read a transaction from stdin and broadcast it");
// Scan
let reset = Arg::with_name("reset")
.long("reset")
.help("Reset wallet state to provided block height and start scanning");
let scan = SubCommand::with_name("scan")
.about("Scan the blockchain and parse relevant transactions")
.args(&[reset]);
// Explorer
let tx_hash = Arg::with_name("tx-hash").help("Transaction hash");
let encode = Arg::with_name("encode").long("encode").help("Encode transaction to base64");
let fetch_tx = SubCommand::with_name("fetch-tx")
.about("Fetch a blockchain transaction by hash")
.args(&[tx_hash, encode]);
let simulate_tx =
SubCommand::with_name("simulate-tx").about("Read a transaction from stdin and simulate it");
let tx_hash = Arg::with_name("tx-hash").help("Fetch specific history record (optional)");
let encode = Arg::with_name("encode")
.long("encode")
.help("Encode specific history record transaction to base64");
let txs_history = SubCommand::with_name("txs-history")
.about("Fetch broadcasted transactions history")
.args(&[tx_hash, encode]);
let clear_reverted =
SubCommand::with_name("clear-reverted").about("Remove reverted transactions from history");
let height = Arg::with_name("height").help("Fetch specific height record (optional)");
let scanned_blocks = SubCommand::with_name("scanned-blocks")
.about("Fetch scanned blocks records")
.args(&[height]);
let mining_config = SubCommand::with_name("mining-config")
.about("Read a mining configuration from stdin and display its parts");
let explorer =
SubCommand::with_name("explorer").about("Explorer related subcommands").subcommands(vec![
fetch_tx,
simulate_tx,
txs_history,
clear_reverted,
scanned_blocks,
mining_config,
]);
// Alias
let alias = Arg::with_name("alias").help("Token alias");
let token = Arg::with_name("token").help("Token to create alias for");
let add = SubCommand::with_name("add").about("Create a Token alias").args(&[alias, token]);
let alias = Arg::with_name("alias")
.short("a")
.long("alias")
.takes_value(true)
.help("Token alias to search for");
let token = Arg::with_name("token")
.short("t")
.long("token")
.takes_value(true)
.help("Token to search alias for");
let show = SubCommand::with_name("show")
.about(
"Print alias info of optional arguments. \
If no argument is provided, list all the aliases in the wallet.",
)
.args(&[alias, token]);
let alias = Arg::with_name("alias").help("Token alias to remove");
let remove = SubCommand::with_name("remove").about("Remove a Token alias").arg(alias);
let alias = SubCommand::with_name("alias")
.about("Manage Token aliases")
.subcommands(vec![add, show, remove]);
// Token
let secret_key = Arg::with_name("secret-key").help("Mint authority secret key");
let token_blind = Arg::with_name("token-blind").help("Mint authority token blind");
let import = SubCommand::with_name("import")
.about("Import a mint authority")
.args(&[secret_key, token_blind]);
let generate_mint =
SubCommand::with_name("generate-mint").about("Generate a new mint authority");
let list =
SubCommand::with_name("list").about("List token IDs with available mint authorities");
let token = Arg::with_name("token").help("Token ID to mint");
let amount = Arg::with_name("amount").help("Amount to mint");
let recipient = Arg::with_name("recipient").help("Recipient of the minted tokens");
let mint = SubCommand::with_name("mint")
.about("Mint tokens")
.args(&[token, amount, recipient, spend_hook, user_data]);
let token = Arg::with_name("token").help("Token ID to freeze");
let freeze = SubCommand::with_name("freeze").about("Freeze a token mint").arg(token);
let token = SubCommand::with_name("token").about("Token functionalities").subcommands(vec![
import,
generate_mint,
list,
mint,
freeze,
]);
// Contract
let generate_deploy =
SubCommand::with_name("generate-deploy").about("Generate a new deploy authority");
let contract_id = Arg::with_name("contract-id").help("Contract ID (optional)");
let list = SubCommand::with_name("list")
.about("List deploy authorities in the wallet (or a specific one)")
.args(&[contract_id]);
let tx_hash = Arg::with_name("tx-hash").help("Record transaction hash");
let export_data = SubCommand::with_name("export-data")
.about("Export a contract history record wasm bincode and deployment instruction, encoded to base64")
.args(&[tx_hash]);
let deploy_auth = Arg::with_name("deploy-auth").help("Contract ID (deploy authority)");
let wasm_path = Arg::with_name("wasm-path").help("Path to contract wasm bincode");
let deploy_ix =
Arg::with_name("deploy-ix").help("Optional path to serialized deploy instruction");
let deploy = SubCommand::with_name("deploy").about("Deploy a smart contract").args(&[
deploy_auth.clone(),
wasm_path,
deploy_ix,
]);
let lock = SubCommand::with_name("lock").about("Lock a smart contract").args(&[deploy_auth]);
let contract = SubCommand::with_name("contract")
.about("Contract functionalities")
.subcommands(vec![generate_deploy, list, export_data, deploy, lock]);
// Main arguments
let config = Arg::with_name("config")
.short("c")
.long("config")
.takes_value(true)
.help("Configuration file to use");
let network = Arg::with_name("network")
.long("network")
.takes_value(true)
.help("Blockchain network to use");
let command = vec![
interactive,
kaching,
ping,
completions,
wallet,
spend,
unspend,
transfer,
otc,
attach_fee,
tx_from_calls,
inspect,
broadcast,
dao,
scan,
explorer,
alias,
token,
contract,
];
let fun = Arg::with_name("fun")
.short("f")
.long("fun")
.help("Flag indicating whether you want some fun in your life");
let log = Arg::with_name("log")
.short("l")
.long("log")
.takes_value(true)
.help("Set log file to ouput into");
let verbose = Arg::with_name("verbose")
.short("v")
.multiple(true)
.help("Increase verbosity (-vvv supported)");
let mut app = App::new("drk")
.about(cli_desc!())
.args(&[config, network, fun, log, verbose])
.subcommands(command);
let shell = match Shell::from_str(shell) {
Ok(s) => s,
Err(e) => return Err(Error::Custom(e)),
};
let mut buf = vec![];
app.gen_completions_to("./drk", shell, &mut buf);
Ok(String::from_utf8(buf)?)
}
/// Auxiliary function to print provided string buffer.
pub fn print_output(buf: &[String]) {
for line in buf {
println!("{line}");
}
}
/// Auxiliary function to print or insert provided messages to given
/// buffer reference. If a channel sender is provided, the messages
/// are send to that instead.
pub async fn append_or_print(
buf: &mut Vec,
sender: Option<&Sender>>,
print: &bool,
messages: Vec,
) {
// Send the messages to the channel, if provided
if let Some(sender) = sender {
if let Err(e) = sender.send(messages).await {
let err_msg = format!("[append_or_print] Sending messages to channel failed: {e}");
if *print {
println!("{err_msg}");
} else {
buf.push(err_msg);
}
}
return
}
// Print the messages
if *print {
for msg in messages {
println!("{msg}");
}
return
}
// Insert the messages in the buffer
for msg in messages {
buf.push(msg);
}
}
/// Auxiliary function to parse a base64 encoded mining configuration
/// from stdin.
pub async fn parse_mining_config_from_stdin(
) -> Result<(String, String, Option, Option)> {
let mut buf = String::new();
stdin().read_to_string(&mut buf)?;
let config = buf.trim();
let (recipient, spend_hook, user_data) = match base64::decode(config) {
Some(bytes) => deserialize_async(&bytes).await?,
None => return Err(Error::ParseFailed("Failed to decode mining configuration")),
};
Ok((config.to_string(), recipient, spend_hook, user_data))
}
/// Auxiliary function to parse a base64 encoded mining configuration
/// from provided input or fallback to stdin if its empty.
pub async fn parse_mining_config_from_input(
input: &[String],
) -> Result<(String, String, Option, Option)> {
match input.len() {
0 => parse_mining_config_from_stdin().await,
1 => {
let config = input[0].trim();
let (recipient, spend_hook, user_data) = match base64::decode(config) {
Some(bytes) => deserialize_async(&bytes).await?,
None => return Err(Error::ParseFailed("Failed to decode mining configuration")),
};
Ok((config.to_string(), recipient, spend_hook, user_data))
}
_ => Err(Error::ParseFailed("Multiline input provided")),
}
}
/// Auxiliary function to display the parts of a mining configuration.
pub fn display_mining_config(
config: &str,
recipient_str: &str,
spend_hook: &Option,
user_data: &Option,
output: &mut Vec,
) {
output.push(format!("DarkFi mining configuration address: {config}"));
match Address::from_str(recipient_str) {
Ok(recipient) => {
output.push(format!("Recipient: {recipient_str}"));
output.push(format!("Public key: {}", recipient.public_key()));
output.push(format!("Network: {:?}", recipient.network()));
}
Err(e) => output.push(format!("Recipient: Invalid ({e})")),
}
let spend_hook = match spend_hook {
Some(spend_hook_str) => match FuncId::from_str(spend_hook_str) {
Ok(_) => String::from(spend_hook_str),
Err(e) => format!("Invalid ({e})"),
},
None => String::from("-"),
};
output.push(format!("Spend hook: {spend_hook}"));
let user_data = match user_data {
Some(user_data_str) => match bs58::decode(&user_data_str).into_vec() {
Ok(bytes) => match bytes.try_into() {
Ok(bytes) => {
if pallas::Base::from_repr(bytes).is_some().into() {
String::from(user_data_str)
} else {
String::from("Invalid")
}
}
Err(e) => format!("Invalid ({e:?})"),
},
Err(e) => format!("Invalid ({e})"),
},
None => String::from("-"),
};
output.push(format!("User data: {user_data}"));
}
/// Cast `ContractCallImport` to `ContractCallLeaf`
fn to_leaf(call: &ContractCallImport) -> ContractCallLeaf {
ContractCallLeaf {
call: call.call().clone(),
proofs: call.proofs().iter().map(|p| Proof::new(p.clone())).collect(),
}
}
/// Recursively build subtree for a DarkTree
fn build_subtree(
idx: usize,
calls: &[ContractCallImport],
children_map: &HashMap>,
) -> DarkTree {
let children_idx = children_map.get(&idx).map(|v| v.as_slice()).unwrap_or(&[]);
let children: Vec> =
children_idx.iter().map(|&i| build_subtree(i, calls, children_map)).collect();
DarkTree::new(to_leaf(&calls[idx]), children, None, None)
}
/// Build a `Transaction` given a slice of calls and their mapping
pub fn tx_from_calls_mapped(
calls: &[ContractCallImport],
map: &[(usize, Vec)],
) -> Result<(TransactionBuilder, Vec)> {
assert_eq!(calls.len(), map.len());
let signature_secrets: Vec =
calls.iter().flat_map(|c| c.secrets().to_vec()).collect();
let children_map: HashMap> = map.iter().map(|(k, v)| (*k, v)).collect();
let (root_idx, root_children_idx) = &map[0];
let root_children: Vec> =
root_children_idx.iter().map(|&i| build_subtree(i, calls, &children_map)).collect();
let tx_builder = TransactionBuilder::new(to_leaf(&calls[*root_idx]), root_children)?;
Ok((tx_builder, signature_secrets))
}
/// Auxiliary function to parse a contract call mapping.
///
/// The mapping is in the format of `{0: [1,2], 1: [], 2:[3], 3:[]}`.
/// It supports nesting and this kind of logic as expected.
///
/// Errors out if there are non-unique keys or cyclic references.
pub fn parse_tree(input: &str) -> std::result::Result)>, String> {
let s = input
.trim()
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.ok_or("expected {}")?
.trim();
let mut entries = vec![];
let mut seen_keys = HashSet::new();
if s.is_empty() {
return Ok(entries)
}
let mut rest = s;
while !rest.is_empty() {
// Parse key
let (key_str, after_key) = rest.split_once(':').ok_or("expected ':'")?;
let key: usize = key_str.trim().parse().map_err(|_| "invalid key")?;
if !seen_keys.insert(key) {
return Err(format!("duplicate key: {}", key));
}
// Parse array
let after_key = after_key.trim();
let arr_start = after_key.strip_prefix('[').ok_or("expected '['")?;
let (arr_content, after_arr) = arr_start.split_once(']').ok_or("expected ']'")?;
let children: Vec = arr_content
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.parse().map_err(|_| "invalid child"))
.collect::>()?;
entries.push((key, children));
// Move to next entry
rest = after_arr.trim().strip_prefix(',').unwrap_or(after_arr).trim();
}
check_cycles(&entries)?;
Ok(entries)
}
fn check_cycles(entries: &[(usize, Vec)]) -> std::result::Result<(), String> {
let graph: HashMap> = entries.iter().map(|(k, v)| (*k, v)).collect();
let mut visited = HashSet::new();
let mut path = Vec::new();
fn dfs(
node: usize,
graph: &HashMap>,
visited: &mut HashSet,
path: &mut Vec,
) -> std::result::Result<(), String> {
if let Some(pos) = path.iter().position(|&n| n == node) {
let cycle: Vec<_> = path[pos..].iter().chain(&[node]).map(|n| n.to_string()).collect();
return Err(format!("cycle detected: {}", cycle.join(" -> ")));
}
if visited.contains(&node) {
return Ok(());
}
path.push(node);
if let Some(children) = graph.get(&node) {
for &child in *children {
dfs(child, graph, visited, path)?;
}
}
path.pop();
visited.insert(node);
Ok(())
}
for &(key, _) in entries {
dfs(key, &graph, &mut visited, &mut path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_tree() {
// Valid inputs
assert_eq!(parse_tree("{}").unwrap(), vec![]);
assert_eq!(parse_tree("{ }").unwrap(), vec![]);
assert_eq!(parse_tree("{ 0: [] }").unwrap(), vec![(0, vec![])]);
assert_eq!(parse_tree("{ 0: [1, 2, 3] }").unwrap(), vec![(0, vec![1, 2, 3])]);
assert_eq!(parse_tree("{0:[],1:[2]}").unwrap(), vec![(0, vec![]), (1, vec![2])]);
assert_eq!(parse_tree("{ 0: [], 1: [], }").unwrap(), vec![(0, vec![]), (1, vec![])]);
assert_eq!(parse_tree("{ 0: [1, 2,] }").unwrap(), vec![(0, vec![1, 2])]);
assert_eq!(
parse_tree("{ 0: [], 1: [2, 3], 2: [], 3: [4], 4: [] }").unwrap(),
vec![(0, vec![]), (1, vec![2, 3]), (2, vec![]), (3, vec![4]), (4, vec![])]
);
assert_eq!(
parse_tree("{ 0 : [ ] , 1 : [ 2 , 3 ] }").unwrap(),
vec![(0, vec![]), (1, vec![2, 3])]
);
assert_eq!(
parse_tree("{ 999: [1000, 1001], 1000: [], 1001: [] }").unwrap(),
vec![(999, vec![1000, 1001]), (1000, vec![]), (1001, vec![])]
);
// Order preservation
let keys: Vec =
parse_tree("{ 5: [], 2: [], 9: [], 0: [] }").unwrap().iter().map(|(k, _)| *k).collect();
assert_eq!(keys, vec![5, 2, 9, 0]);
// Valid DAG (not a cycle)
assert!(parse_tree("{ 0: [1, 2], 1: [3], 2: [3], 3: [] }").is_ok());
// Syntax errors
assert!(parse_tree("0: [] }").is_err());
assert!(parse_tree("{ 0: []").is_err());
assert!(parse_tree("{ 0 [] }").is_err());
assert!(parse_tree("{ 0: ] }").is_err());
assert!(parse_tree("{ 0: [1, 2 }").is_err());
assert!(parse_tree("{ abc: [] }").is_err());
assert!(parse_tree("{ 0: [abc] }").is_err());
assert!(parse_tree("{ -1: [] }").is_err());
// Duplicate keys
assert!(parse_tree("{ 0: [], 0: [1] }").unwrap_err().contains("duplicate key: 0"));
assert!(parse_tree("{ 0: [], 1: [], 2: [], 1: [] }")
.unwrap_err()
.contains("duplicate key: 1"));
// Cycle detection
let err = parse_tree("{ 0: [0] }").unwrap_err();
assert!(err.contains("cycle detected") && err.contains("0 -> 0"));
let err = parse_tree("{ 0: [1], 1: [0] }").unwrap_err();
assert!(err.contains("cycle detected"));
let err = parse_tree("{ 0: [1], 1: [2], 2: [3], 3: [0] }").unwrap_err();
assert!(err.contains("cycle detected") && err.contains("0 -> 1 -> 2 -> 3 -> 0"));
let err = parse_tree("{ 0: [1], 1: [2], 2: [3], 3: [2] }").unwrap_err();
assert!(err.contains("cycle detected") && err.contains("2 -> 3 -> 2"));
}
}