Este artigo é parte guia, parte postagem sobre o que você deve considerar antes de criar um novo aplicativo de CLI (Interface de Linha de Comando) em Rust.
Foto de Marc Mintel no Unsplash
Gostou deste artigo? Tem perguntas? Siga-me no twitter e pergunte.
Antes de mergulhar no comando cargo new
e começar a desenvolver, leia e veja como melhorar sua experiência de desenvolvimento, a ergonomia da CLI e manutenibilidade do projeto.
Estrutura
Quando você tem um aplicativo de CLI, você tem vários sinalizadores e comandos e, para eles, o módulo lógico adequado.
Por exemplo, para um subcomando git clone
, você tem alguma funcionalidade de clonagem, mas para um subcomando git commit
você pode ter uma funcionalidade completamente diferente que pode residir em um módulo completamente diferente (e deveria). Portanto, este pode ser um módulo diferente e também um arquivo diferente.
Ou você pode ter uma estrutura plana simples de um aplicativo de CLI que faz apenas uma coisa, mas recebe vários sinalizadores como ajustes para essa única coisa:
$ demo --help
A simple to use, efficient, and full-featured Command Line Argument Parser
Usage: demo[EXE] [OPTIONS] --name <NAME>
Options:
-n, --name <NAME> Name of the person to greet
-c, --count <COUNT> Number of times to greet [default: 1]
-h, --help Print help
-V, --version Print version
$ demo --name Me
Hello Me!
Portanto, quando falamos sobre um layout de comandos, isso deve significar a divisão em arquivos, e também queremos falar sobre a estrutura de arquivos e o layout do projeto.
Depois de revisar um bom número de aplicativos de CLI populares no Rust, descobri que geralmente existem três tipos de estruturas de aplicativos:
- Ad-hoc, veja
xh
como exemplo, com qualquer estrutura de pastas. - Plana, com uma estrutura de pastas como:
src/
main.rs
clone.rs
..
-
Sub-comandos, onde a estrutura é aninhada, aqui está
delta
como exemplo, com uma estrutura de pastas como:
src/
cmd/
clone.rs
main.rs
Você pode optar por uma estrutura plana ou aninhada usando meu projeto inicial: rust-starter
e use o que precisa, exclua o que não precisa.
Enquanto você está nisso, é sempre bom dividir o núcleo do seu aplicativo e sua interface. Acho que uma boa regra é pensar em criar:
- Uma biblioteca.
- Uma CLI que usa esta biblioteca.
- E algo que ajuda a generalizar e solidificar a API desta biblioteca: Alguma outra GUI (que nunca existirá) que possa usar esta biblioteca.
Na maioria das vezes, especialmente em Rust, descobri que essa divisão foi extremamente útil e os casos de uso se apresentam a mim para usar a biblioteca como uma biblioteca por si só.
Normalmente, a divisão é feita em três partes:
- Uma biblioteca.
- Um fluxo de trabalho/executor principal, (por exemplo,
workflow.rs
) que está conduzindo o aplicativo de CLI. - As partes da CLI (prompt, tratamento de saída, análise de sinalizadores, e comandos) que são mapeadas para o fluxo de trabalho.
Sinalizadores, Opções, Argumentos e Comandos
Mesmo se descartarmos aplicativos de CLI que são muito gráficos como o Vim (que usa TUI, etc.), ainda temos que abordar questões de UI/UX em nossa CLI, na forma de sinalizadores, comandos e argumentos que um programa recebe.
Existem várias maneiras diferentes de especificar e analisar opções de linha de comando, cada uma com seu próprio conjunto de convenções e práticas recomendadas.
De acordo com o padrão POSIX, as opções são especificadas usando um único traço seguido de uma única letra e podem ser combinadas em um único argumento (por exemplo, -abc
). As opções longas, que geralmente são mais descritivas e fáceis de ler, são especificadas usando dois traços seguidos de uma palavra (por exemplo, --option
). As opções também podem aceitar valores, que são especificados usando um sinal de igual (por exemplo, -o=value
ou --option=value
).
Argumentos posicionais são os argumentos que um aplicativo de linha de comando espera que sejam especificados em uma ordem específica. Eles não são prefixados com um traço e geralmente são usados para especificar os dados necessários que o aplicativo precisa para funcionar corretamente.
O padrão POSIX também define várias opções especiais, como -h
ou--help
para exibir uma mensagem de ajuda e -v
ou --verbose
para saída detalhada. Essas opções são amplamente reconhecidas e usadas por muitos aplicativos de linha de comando, tornando mais fácil para os usuários descobrir e usar diferentes recursos.
No geral, o padrão POSIX fornece um conjunto de convenções para especificar e analisar opções de linha de comando que são amplamente reconhecidas e seguidas por muitos aplicativos de linha de comando, tornando mais fácil para os usuários entender e usar diferentes ferramentas de linha de comando.
Ou seja, ao projetar a interface de um aplicativo de linha de comando, precisamos pensar em:
-
Subcomandos: Alguns aplicativos de linha de comando permitem que os usuários especifiquem subcomandos, que são essencialmente subaplicativos adicionais que podem ser executados no aplicativo principal. Por exemplo, o comando
git
permite que os usuários especifiquem subcomandos comocommit
,push
epull
. Os subcomandos costumam ser usados para agrupar funcionalidades relacionadas em um único aplicativo e facilitar a descoberta e o uso de diferentes recursos pelos usuários. -
Argumentos posicionais: Argumentos posicionais são os argumentos que um aplicativo de linha de comando espera que sejam especificados em uma ordem específica. Por exemplo, o comando
cp
espera dois argumentos posicionais: O arquivo de origem e o arquivo de destino. Os argumentos posicionais geralmente são usados para especificar os dados necessários que o aplicativo precisa para funcionar corretamente. -
Sinalizadores/opções: Sinalizadores são opções de linha de comando que não esperam que um valor seja especificado. Eles geralmente são usados para alternar um determinado comportamento ou configuração no aplicativo. Os sinalizadores são normalmente especificados usando um único traço seguido por uma única letra (por exemplo,
-v
para saída detalhada) ou um traço duplo seguido por uma palavra (por exemplo,--verbose
para saída detalhada). Os sinalizadores também podem aceitar valores opcionais, que são especificados usando um sinal de igual (por exemplo,--output=file.txt
).
Para obter uma ótima biblioteca na qual você possa confiar, que o levará de um aplicativo simples a um complexo sem a necessidade de migrar para outra biblioteca, você pode usar o crate clap. O clap irá levá-lo por um longo caminho; considere usar uma alternativa apenas se você tiver um requisito especial, como tempo de compilação mais rápido, binário menor ou similar.
Command::new("git")
.about("A fictional versioning CLI")
.subcommand_required(true)
.arg_required_else_help(true)
.allow_external_subcommands(true)
.subcommand(
Command::new("clone")
.about("Clones repos")
.arg(arg!(<REMOTE> "The remote to clone"))
.arg_required_else_help(true),
)
.subcommand(
Command::new("diff")
.about("Compare two commits")
.arg(arg!(base: [COMMIT]))
.arg(arg!(head: [COMMIT]))
.arg(arg!(path: [PATH]).last(true))
.arg(
arg!(--color <WHEN>)
.value_parser(["always", "auto", "never"])
.num_args(0..=1)
.require_equals(true)
.default_value("auto")
.default_missing_value("always"),
),
)
.subcommand(
Command::new("push")
.about("pushes things")
.arg(arg!(<REMOTE> "The remote to target"))
.arg_required_else_help(true),
)
.subcommand(
Command::new("add")
.about("adds things")
.arg_required_else_help(true)
.arg(arg!(<PATH> ... "Stuff to add").value_parser(clap::value_parser!(PathBuf))),
)
.subcommand(
Command::new("stash")
.args_conflicts_with_subcommands(true)
.args(push_args())
.subcommand(Command::new("push").args(push_args()))
.subcommand(Command::new("pop").arg(arg!([STASH])))
.subcommand(Command::new("apply").arg(arg!([STASH]))),
)
Uma alternativa minimalista para o clap é argh. Por que escolher outra coisa?
- O
clap
pode ser muito grande em tamanho para o seu binário, e você realmente se importa com o tamanho do binário (estamos falando de números como 300kb vs 50kb aqui). - O
clap
pode demorar mais para compilar (mas é rápido o suficiente para a maioria das pessoas). - Você pode ter uma interface de CLI muito simples e apreciar um código que cabe em meia tela para tudo o que você precisa fazer.
Aqui está um exemplo usando o argh
#[derive(Debug, FromArgs)]
struct AppArgs {
/// tarefa para executar
#[argh(positional)]
task: Option<String>,
/// lista de tarefas
#[argh(switch, short = 'l')]
list: bool,
/// caminho da raiz (padrão ".")
#[argh(option, short = 'p')]
path: Option<String>,
/// inicialização da configuração local
#[argh(switch, short = 'i')]
init: bool,
}
E então apenas:
let args: AppArgs = argh::from_env();
Configuração
Na maioria dos sistemas operacionais, existem locais padrão para armazenar arquivos de configuração e outros dados específicos do usuário. Esses locais geralmente são chamados de “pastas iniciais” ou “diretórios de perfil” e são usados para armazenar arquivos de configuração, dados de aplicativos e outros dados específicos do usuário.
Sistemas do tipo Unix (Linux / macOS)
A pasta inicial geralmente está localizada em /home/username
ou /Users/username
, e é usada para armazenar arquivos de configuração e outros dados específicos do usuário. A pasta inicial é geralmente chamada de diretório $HOME
e pode ser acessada usando o símbolo~
(por exemplo ~/.bashrc
).
Dentro do diretório inicial, geralmente há uma pasta .config
(também conhecida como "diretório de configuração") que é usada para armazenar arquivos de configuração e outros dados específicos do usuário. A pasta .config
é um diretório “ponto” (oculto), portanto, se você não o vir, use um terminal. Um aplicativo pode armazenar seus arquivos de configuração em um subdiretório da pasta .config
, como~/.config/myapp
No Windows, a pasta inicial geralmente está localizada em C:\\Users\\username
, e é usada para armazenar arquivos de configuração e outros dados específicos do usuário. A pasta inicial geralmente é chamada de diretório "perfil do usuário" e pode ser acessada usando a variável de ambiente %USERPROFILE%
(por exemplo %USERPROFILE%\\AppData\\Roaming
).
Para ocultar toda essa complexidade, você pode usar o crate dirs.
dirs::home_dir();
// Lin: Some(/home/alice)
// Win: Some(C:\\Users\\Alice)
// Mac: Some(/Users/Alice)
dirs::config_dir();
// Lin: Some(/home/alice/.config)
// Win: Some(C:\\Users\\Alice\\AppData\\Roaming)
// Mac: Some(/Users/Alice/Library/Application Support)
Arquivos de configuração
A leitura do conteúdo da configuração é fácil com o serde
, pois ele o transforma em um processo de três etapas:
- “Modele” sua configuração como uma estrutura.
- Escolha um formato e inclua os recursos do serde necessários (por exemplo
yaml
). - Desserialize sua configuração (
serde
::from_str
).
Na maioria das vezes, isso é mais do que suficiente e resulta em um código simples e de fácil manutenção que também pode ser desenvolvido ao evoluir a forma de sua estrutura.
impl Config {
pub fn load(path: &Path)->Result<Self>{
Ok(serde::from_str(&fs::read_to_string(path)?)?)
}
..
}
Alguns casos de uso exigem uma “atualização” do carregamento da configuração de duas maneiras possíveis:
- Configuração local vs. global e suas relações. Ou seja, leia o arquivo de configuração da pasta atual e “aumente” a hierarquia da pasta, completando o restante da configuração ausente com cada novo arquivo de configuração que encontrar até chegar ao arquivo de configuração global do usuário, que reside em algo como
~/.your-app/config.yaml
- Entradas de configuração em camadas. Ou seja, lendo de um arquivo local
config
.yaml
mas, se um determinado valor foi fornecido por meio de um sinalizador de ambiente ou sinalizador de CLI, substitua o que foi encontrado emconfig.yaml
por esse valor. Isso exige uma biblioteca que possa fornecer alinhamento de chaves de configuração para vários formatos: YAML, sinalizadores de CLI, variáveis de ambiente e assim por diante.
Embora eu recomende fortemente que você mantenha as coisas simples e evolua (basta usar o carregamento do serde
), achei essas duas bibliotecas realmente robustas na leitura da configuração em camadas, e fazem praticamente a mesma coisa:
Cores, estilo e o terminal
No mundo de hoje, tornou-se totalmente normal expressar-se por meio do estilo no terminal. Isso significa cores, às vezes cores RGB, emoji, animação e muito mais. O uso de cores, unicode, corre o risco de deixar de ser compatível com um terminal Unix “tradicional”, mas a maioria das bibliotecas pode detectar e adaptar a experiência conforme necessário.
Cores
Se você não estiver usando nenhuma outra biblioteca de UX de terminal, a owo-colors é ótima, minimalista, consome poucos recursos e divertida:
use owo_colors::OwoColorize;
fn main() {
// Cores de primeiro plano
println!("My number is {:#x}!", 10.green());
// Cores de fundo
println!("My number is not {}!", 4.on_red());
}
Se você estiver usando algo como dialoguer
para prompts, vale a pena examinar o que ele usa para cores. Nesse caso, ele usa console
para manipulação de terminal. Com
você pode estilizá-lo desta maneira:console
use console::Style;
let cyan = Style::new().cyan();
println!("This is {} neat", cyan.apply_to("quite"))
Um pouco diferente, mas não muito diferente.
Emoji
Pensando nos emojis: Eles são uma forma de expressão. Então, 🙂, :-)
e [smiling
] são todos a mesma expressão, mas meios diferentes. Você deseja o emoji onde você tem um bom suporte a unicode, o smiley de texto em terminais de texto sem unicode e o
detalhado quando você deseja um texto pesquisável ou uma saída mais acessível e legível para deficientes visuais.smiling
Outra dica a ser lembrada é que o Emoji pode parecer diferente em diferentes terminais. No Windows, você tem o terminal antigo cmd.exe
e o Powershell, e eles são radicalmente diferentes na forma como renderizam Emojis de terminais Linux e macOS (enquanto a renderização de Emojis do Linux e do macOS é bem semelhante).
Por falar nisso, é melhor abstrair seus emojis literais em variáveis. Pode ser apenas um monte de literais com sua própria lógica de comutação ou algo mais sofisticado com uma implementação fmt::Display
.
Você pode querer mudar com base em uma matriz de requisitos:
- Sistema operacional.
- Suporte a recursos de terminal (unicode, istty).
- Valor solicitado pelo usuário (eles não pediram emojis especificamente?)
Há uma ótima implementação dessa ideia (embora não tão extensa) em console
.
use console::Emoji;
println!("[3/4] {}Downloading ...", Emoji("🚚 ", ""));
println!("[4/4] {} Done!", Emoji("✨", ":-)"));
Tabelas
Uma das bibliotecas mais flexíveis para impressão e formatação de tabelas é a tabled. O que torna flexível uma biblioteca de impressão de tabelas?
- Suporte a “dados de formato livre” — apenas um conjunto de linhas e nomes de colunas.
- Suporte a registros tipados por meio do
serde
para que você possa fornecer um monte de elementos tipados em um Vec. - Formatação e modelagem: Alinhamento, espaçamento, abrangência e muito mais.
- Suporte a cores - este não é tão fácil, pois quando você está calculando um layout de uma tabela, você precisa considerar os códigos ANSI que tornam uma string byte a byte mais longa e difícil de prever.
- E muito mais.
tabled
faz tudo isso e é ótimo. Se você quiser imprimir resultados de tabela ou apenas resultados de layout de tabela (como resultados de formatação de página), não procure por mais nada, esta é a escolha certa.
Prompts
O dialoguer
é a biblioteca de prompt mais amplamente usada no momento e é sólida como uma rocha. Ela tem quase todos os diferentes prompts que você esperaria de um aplicativo de CLI versátil, como caixa de seleção, seleção de opções e seleções fuzzy.
let items = vec!["Item 1", "item 2"];
let selection = FuzzySelect::with_theme(&ColorfulTheme::default())
.items(&items)
.default(0)
.interact_on_opt(&Term::stderr())?;
match selection {
Some(index) => println!("User selected item : {}", items[index]),
None => println!("User did not select anything")
}
O dialoguer
tem um grande problema — que é a falta de um histórico para testabilidade. Ou seja, se você quer testar seu código e ele depende dos testes, você terá uma forte dependência do terminal (seus testes vão travar).
O que você esperaria é ter algum tipo de recurso de abstração de E/S que possa injetar nos testes, alimentar as teclas digitadas programaticamente e verificar se foram lidas e se as ações apropriadas foram executadas.
Outra biblioteca é a inquire
, mas ela também sofre por não ter um histórico de testes, e você pode ver como isso pode ser complexo em um problema que estou acompanhando.
A boa notícia é que você tem um histórico de testes bem satisfatório com a biblioteca menos popular requestty
. De qualquer forma, ao injetar essa camada de abstração de E/S, você também deve pensar em mutabilidade e propriedade:
pub struct Prompt<'a> {
config: &'a Config,
events: Option<TestEvents<IntoIter<KeyEvent>>>,
show_progress: bool,
}
impl<'a> Prompt<'a> {
pub fn build(config: &'a Config, show_progress: bool, events: Option<&RunnerEvents>) -> Self {
events
.and_then(|evs| evs.prompt_events.as_ref())
.map_or_else(
|| Prompt::new(config, show_progress),
|evs| Prompt::with_events(config, evs.clone()),
)
}
...
Não é uma visão bonita, mas resulta em um fluxo de interação de CLI bem testado.
Quais outras opções você tem para trabalhar com os testes?
- Confie no estado estável do dialoguer e simplesmente não teste as partes de interação do seu aplicativo.
- Teste a interação por meio de testes de caixa-preta (mais sobre testes posteriormente). Você pode ir muito longe com essa abordagem em termos de ROI (retorno sobre o investimento) em testes.
- Crie seu próprio equipamento de teste, com uma camada de interação de interface do usuário comutável, onde você a substitui completamente durante o teste por algo que repita a ação (novamente, seu próprio código personalizado). Isso significa que o código real que lida com prompts e seleções nunca será testado, por menor que seja o código.
Status e Progresso
indicatif
é a biblioteca de referência em Rust para barras de status e progresso. Existe apenas uma ótima biblioteca disponível, e isso é bom porque não estamos presos no paradoxo das opções, então use esta!
Operabilidade
Existem principalmente dois tipos de registro para operabilidade no Rust e você pode usar ambos ou um deles:
- Logging — E o que evoluiu como padrão: https://lib.rs/crates/log, onde as pessoas geralmente o combinam com o env_logger, que é simples e muito fácil de usar.
env_logger::init();
info!("starting up");
$ RUST_LOG=INFO ./main[2018-11-03T06:09:06Z INFO default] starting up
-
Rastreamento — Para isso, você precisa usar a única crate
tracing
e o ecossistema que o Rust possui (felizmente há apenas um!). Você pode começar com otracing-tree
, mas também pode decidir mais tarde conectar a telemetria e SDKs de terceiros, bem como imprimir gráficos de chama, como seria de esperar de uma infraestrutura de rastreamento
server{host="localhost", port=8080}
0ms INFO starting
300ms INFO listening
conn{peer_addr="82.9.9.9", port=42381}
0ms DEBUG connected
300ms DEBUG message received, length=2
conn{peer_addr="8.8.8.8", port=18230}
300ms DEBUG connected
conn{peer_addr="82.9.9.9", port=42381}
600ms WARN weak encryption requested, algo="xor"
901ms DEBUG response sent, length=8
901ms DEBUG disconnected
conn{peer_addr="8.8.8.8", port=18230}
600ms DEBUG message received, length=5
901ms DEBUG response sent, length=8
901ms DEBUG disconnected
1502ms WARN internal error
1502ms INFO exit
O rastreamento permite instrumentar seu código com muita facilidade decorando funções:
#[tracing::instrument(level = "trace", skip_all, err)]
pub fn is_archive(file: &File, fval: &[String]) -> Result<Option<bool>>
E você pode ter a opção de capturar argumentos, retornar valores e erros da função automaticamente.
Manipulação de erros
Este é um grande problema no Rust. Simplesmente porque os erros passaram por uma grande fase de evolução. Houve algumas bibliotecas que decolaram, depois desapareceram, e mais algumas que ganharam popularidade e morreram.
Enfim, foi um processo fantástico. Entre cada ciclo, o ecossistema Rust teve aprendizados reais e melhorias feitas, e hoje temos algumas bibliotecas que são realmente ótimas.
O lado negativo disso é que, dependendo do código que você lerá, dos exemplos aqui e ali, e dos projetos de código aberto - você precisará fazer uma anotação mental de quais bibliotecas ele usa e para qual era de bibliotecas de erro ele pertence.
Portanto, no momento em que escrevo este artigo, essas são as bibliotecas que considero perfeitas para um aplicativo de CLI. Aqui também, divido meu pensamento em “erros de aplicativo” e “erros de biblioteca” onde, para erros de biblioteca, você deseja usar tipos de erro que dependem da biblioteca padrão e não forçam seus usuários a usar uma biblioteca de erros especializada.
- Erros de aplicativo:
eyre
, que é um parente próximo daanyhow
, mas tem um ótimo histórico de relatório de erros com bibliotecas como acolor-eyre.
- Erros de biblioteca: Eu costumava usar a
thiserror
, mas depois mudei para asnafu
o que uso para tudo. Asnafu
lhe dá todas as vantagens,this
-error
mas com a ergonomiaanyhow
oueyre.
E então, eu uso bibliotecas que melhoram seus relatórios de erros. Principalmente, eu uso a fs_err em vez da std
::fs
, que possui a mesma API, mas com erros mais elaborados e amigáveis, por exemplo:
failed to open file `does not exist.txt`
caused by: The system cannot find the file specified. (os error 2)
Em vez de
The system cannot find the file specified. (os error 2)
Teste
Acho que equilibrar tipos e estratégias de teste no Rust pode ser uma tarefa muito delicada, mas gratificante. Ou seja, o Rust é seguro. Isso não significa que seu código funcione bem desde o início, mas significa que, além de ser uma linguagem estaticamente tipada com tipos que protegem contra muitos erros de programação, ela também é segura no sentido de que elimina uma ampla gama de erros de programação relacionados ao compartilhamento de dados e propriedade.
Eu diria com cuidado que meu código Rust tem menos testes em comparação com meu código Ruby ou JavaScript e é mais sólido.
Acho que essa propriedade do Rust também traz de volta o teste da caixa-preta em grande estilo. Porque uma vez que você testou alguns dos componentes internos, combinar módulos e integrá-los é bastante seguro em virtude do compilador.
Portanto, em suma, minha estratégia de teste para aplicativos de CLI do Rust é:
- Testes de unidade — Testando a lógica em funções e módulos.
- Testes de integração — Conforme necessidade, entre módulos e testando fluxos de trabalho de interação complexos.
-
Testes da caixa-preta - Usando ferramentas como
try_cmd PARA
executar uma sessão de CLI, fornecer entrada, capturar saída e capturar o estado resultante para ser aprovado e salvo.
Eu uso testes de snapshot sempre que possível, pois não faz sentido codificar da esquerda para a direita nos testes:
-
insta
- https://docs.rs/insta/latest/insta/, cuida do fluxo de trabalho de desenvolvimento, criação de snapshots, revisão e recursos extras, como redação e vários formatos de serialização para os snapshots.
#[test] fn test_simple() { insta::assert_yaml_snapshot!(calculate_value()); }
trycmd
- https://lib.rs/crates/trycmd, é uma biblioteca realmente robusta e funciona bem, além de ser incrivelmente simples. Você escreve seus testes como um arquivo markdown e ela irá analisar, executar os comandos incorporados e acompanhar quais resultados devem ser comparados com o resultado no mesmo arquivo markdown, para que seus testes também funcionem como documentação viva.
´´´console
$ my-cmd
Hello world
Liberando e Enviando um Binário
Com o tempo, transformei meu fluxo de trabalho de lançamento em projetos iniciais e ferramentas, portanto, em vez de detalhar e mostrar como criar um fluxo de trabalho do zero, basta usar essas ferramentas e projetos. E se você estiver curioso - leia o código deles.
Se você quiser se divertir mais facilmente aqui, pode dar uma olhada nisso:
- rustwrap — Para liberar binários construídos de lançamentos do Github em homebrew ou npm.
- xtaskops — Para mover alguma lógica de sua CI (Integração Contínua) para o Rust na forma do padrão xtask — Leia mais em “Executando tarefas Rust com xtask e xtaskops”.
- rust-starter — Para levar os fluxos de trabalho de CI prontos para teste simplificado, verificações de lint e lançamento.
Se você tiver um binário Rust adequado, também deve considerar o lançamento para bins do cargo, consulte cargo-binstall
para saber mais sobre isso.
Artigo escrito por Dotan Hahum. Traduzido por Marcelo Panegali.
Top comments (0)