#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, ResponseTemplate};
mod harness;
use harness::{Harness, fixture};
async fn tracker_answers(harness: &Harness) {
let json = |name: &str| ResponseTemplate::new(200).set_body_json(fixture(name));
for (route, body) in [
("/v3/issues/PROJ-1", "issue.json"),
("/v3/issues/PROJ-1/links", "issue_links.json"),
("/v3/issues/PROJ-1/comments", "issue_comments.json"),
("/v3/issues/PROJ-1/changelog", "changelog.json"),
("/v3/issues/PROJ-1/remotelinks", "issue_remotelinks.json"),
("/v3/queues", "queues.json"),
("/v3/queues/PROJ", "queue.json"),
("/v3/queues/PROJ/fields", "queue_fields.json"),
("/v3/queues/PROJ/versions", "queue_versions.json"),
("/v3/queues/PROJ/tags", "queue_tags.json"),
("/v3/queues/PROJ/macros", "queue_macros.json"),
("/v3/queues/PROJ/permissions", "queue_permissions.json"),
("/v3/queues/PROJ/access", "queue_access.json"),
("/v3/myself", "user.json"),
(
"/v3/bulkchange/6a92d90773c59502bc8e028a",
"bulkchange_complete.json",
),
("/v3/queues/PROJ/localFields", "queue_local_fields.json"),
("/v3/sprints", "all_sprints.json"),
("/v3/sprints/21", "sprint.json"),
("/v3/queues/PROJ/autoactions", "queue_autoactions.json"),
("/v3/queues/PROJ/triggers", "queue_triggers.json"),
("/v3/fields", "fields.json"),
("/v3/components", "components.json"),
("/v3/linktypes", "linktypes.json"),
("/v3/fields/storyPoints", "field.json"),
("/v3/issueTemplates", "issue_templates.json"),
("/v3/boards", "boards.json"),
("/v3/boards/6", "board.json"),
("/v3/boards/9/sprints", "sprints.json"),
("/v3/issuetypes", "issuetypes.json"),
("/v3/priorities", "priorities.json"),
("/v3/statuses", "statuses.json"),
("/v3/resolutions", "resolutions.json"),
("/v3/users/ilubenets", "user.json"),
] {
Mock::given(method("GET"))
.and(path(route))
.respond_with(json(body))
.mount(&harness.server)
.await;
}
Mock::given(method("POST"))
.and(path("/v3/issues/_search"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(fixture("issue_search.json"))
.append_header("X-Total-Count", "2"),
)
.mount(&harness.server)
.await;
entity_answers(harness).await;
Mock::given(method("GET"))
.and(path("/v3/boards/6/sprints"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"errors": {},
"errorMessages": ["A board of this type cannot have sprints."],
"statusCode": 400
})))
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/worklog"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"id": 1,
"issue": {"key": "PROJ-1", "display": "Attachments are lost on move"},
"duration": "PT1H30M",
"start": "2026-08-24T09:00:00.000+0300",
"createdBy": {"id": "1", "login": "ilubenets", "display": "Ilya Lubenets"},
"comment": "pairing"
}
])))
.mount(&harness.server)
.await;
Mock::given(method("GET"))
.and(path("/v3/users"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(fixture("users.json"))
.append_header("X-Total-Count", "4"),
)
.mount(&harness.server)
.await;
Mock::given(method("POST"))
.and(path("/v3/issues/_count"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(2)))
.mount(&harness.server)
.await;
}
async fn entity_answers(harness: &Harness) {
let json = |name: &str| ResponseTemplate::new(200).set_body_json(fixture(name));
let parent = serde_json::json!({
"filter": {"parentEntity": "655a1d0c5f1b2c0011223344"}
});
for (kind, listing, contained) in [
(
"portfolio",
"entities_portfolio.json",
"entities_in_portfolio.json",
),
(
"project",
"entities_project.json",
"projects_in_portfolio.json",
),
] {
Mock::given(method("POST"))
.and(path(format!("/v3/entities/{kind}/_search")))
.and(body_json(parent.clone()))
.respond_with(json(contained))
.mount(&harness.server)
.await;
Mock::given(method("POST"))
.and(path(format!("/v3/entities/{kind}/_search")))
.and(body_json(serde_json::json!({})))
.respond_with(json(listing))
.mount(&harness.server)
.await;
}
Mock::given(method("POST"))
.and(path("/v3/entities/goal/_search"))
.respond_with(json("entities_goal.json"))
.mount(&harness.server)
.await;
for (route, body) in [
(
"/v3/entities/portfolio/655a1d0c5f1b2c0011223355",
"entity_portfolio.json",
),
(
"/v3/entities/project/655a1d0c5f1b2c0011223366",
"entity_project.json",
),
(
"/v3/entities/goal/655a1d0c5f1b2c0011223388",
"entity_goal.json",
),
] {
Mock::given(method("GET"))
.and(path(route))
.respond_with(json(body))
.mount(&harness.server)
.await;
}
}
#[tokio::test]
async fn the_documented_examples_still_produce_the_documented_output() {
let harness = Harness::new().await;
tracker_answers(&harness).await;
trycmd::TestCases::new()
.default_bin_name("ytcli")
.env("YTCLI_CONFIG", harness.config_path().display().to_string())
.env("YTCLI_BASE_URL", harness.server.uri())
.env("YTCLI_TOKEN", "test-token")
.env("YTCLI_PROFILE", "test")
.case("tests/docs/*.md")
.run();
}
const UNRUNNABLE: &[(&str, &str)] = &[
("auth login", "interactive; asks for a token"),
("auth logout", "would touch the keychain"),
(
"auth status",
"several requests per profile, and prints identity",
),
("auth list", "prints the profiles of whoever runs it"),
("auth use", "rewrites the config of whoever runs it"),
("issue create", "a write"),
("queue create", "a write"),
("project create", "a write"),
("project update", "a write"),
("project delete", "a write"),
("issue update", "a write"),
("issue comment", "a write"),
("issue transition", "a write"),
("issue move", "a write"),
("issue worklogs", "no recorded worklog fixture yet"),
("issue checklist", "no recorded checklist fixture yet"),
("issue worklog", "a write"),
("issue timer", "a write, and it keeps state between runs"),
(
"issue timers",
"reports whatever the machine running the docs happens to be timing",
),
("issue check", "a write"),
("issue link", "a write"),
("portfolio place", "a write"),
("project place", "a write"),
(
"sprint get",
"how far through a sprint is depends on today's date",
),
("attachment list", "no recorded attachment fixture yet"),
(
"attachment show",
"draws pixels, or names a download command with a real id",
),
("attachment download", "writes a file"),
("attachment delete", "a write, and the one with no undo"),
("attachment upload", "a write"),
];
#[test]
fn every_documented_command_is_either_run_or_declared_unrunnable() {
let cases = read("tests/docs/readme.md") + &read("tests/docs/cheatsheet.md");
for (source, text) in documentation() {
for command in commands_in(&text) {
if UNRUNNABLE.iter().any(|(name, _)| *name == command) {
continue;
}
assert!(
cases.contains(&format!("$ ytcli {command}")),
"{source} documents `ytcli {command}`, which no case runs. \
Add one to tests/docs/, or add it to UNRUNNABLE with a reason."
);
}
}
}
fn read(relative: &str) -> String {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(relative);
std::fs::read_to_string(&path).unwrap_or_else(|_| panic!("{relative} is readable"))
}
fn documentation() -> Vec<(&'static str, String)> {
vec![
("README.md", read("README.md")),
("docs/cheatsheet.txt", read("docs/cheatsheet.txt")),
]
}
fn commands_in(text: &str) -> Vec<String> {
let mut found: Vec<String> = text
.lines()
.filter_map(|line| {
let rest = line.trim().strip_prefix("ytcli ")?;
let path: Vec<&str> = rest
.split_whitespace()
.take_while(|word| word.chars().all(|c| c.is_ascii_lowercase()))
.take(2)
.collect();
(path.len() == 2).then(|| path.join(" "))
})
.collect();
found.sort();
found.dedup();
found
}