File forgejo-cli-0.3.0.obscpio of Package forgejo-cli

07070100000000000081A4000000000000000000000001681904CF0000001F000000000000000000000000000000000000001D00000000forgejo-cli-0.3.0/.gitignore/target

# Nix output
/result*
07070100000001000041ED000000000000000000000002681904CF00000000000000000000000000000000000000000000001E00000000forgejo-cli-0.3.0/.woodpecker07070100000002000081A4000000000000000000000001681904CF000000DC000000000000000000000000000000000000002800000000forgejo-cli-0.3.0/.woodpecker/check.ymlwhen:
  - event: manual
  - event: pull_request
steps:
  check:
    image: rust
    commands:
      - cargo check
  check-fmt:
    image: rust
    commands:
      - rustup component add rustfmt
      - cargo fmt --check
07070100000003000081A4000000000000000000000001681904CF0000088E000000000000000000000000000000000000002900000000forgejo-cli-0.3.0/.woodpecker/deploy.ymlwhen:
  - event: tag
steps:
  compile-linux:
    image: rust:latest
    environment:
      BUILD_TYPE: "release ci"
      BUILTIN_CLIENT_IDS:
        from_secret: builtin_client_ids 
    commands:
      - rustup target add x86_64-unknown-linux-gnu
      - cargo build --target=x86_64-unknown-linux-gnu --release --features update-check
      - strip target/x86_64-unknown-linux-gnu/release/fj
  compile-windows:
    image: rust:latest
    environment:
      BUILD_TYPE: "release ci"
      BUILTIN_CLIENT_IDS:
        from_secret: builtin_client_ids 
    commands:
      - rustup target add x86_64-pc-windows-gnu
      - apt update
      - apt install gcc-mingw-w64-x86-64 -y
      - cargo build --target=x86_64-pc-windows-gnu --release --features update-check
      - strip target/x86_64-pc-windows-gnu/release/fj.exe
  zip:
    image: debian:12
    commands:
      - apt update
      - apt install zip -y
      - cd target/x86_64-pc-windows-gnu/release
      - zip ../../../forgejo-cli-windows.zip fj.exe
      - cd ../../..
      - gzip -c target/x86_64-unknown-linux-gnu/release/fj > forgejo-cli-linux.gz
  deploy-container:
    image: gcr.io/kaniko-project/executor:debug
    environment:
      TOKEN:
        from_secret: token
    commands:
      - export FORGE_HOST=$(echo $CI_FORGE_URL | sed -E 's_^https?://__')
      - export AUTH="$(echo -n $CI_REPO_OWNER:$TOKEN | base64)"
      - echo "{\"auths\":{\"$FORGE_HOST\":{\"auth\":\"$AUTH\"}}}" > "/kaniko/.docker/config.json"
      - export CONTAINER_OWNER=$(echo $CI_REPO_OWNER | awk '{print tolower($0)}')
      - executor --context ./ --dockerfile ./Dockerfile --destination "$FORGE_HOST/$CONTAINER_OWNER/forgejo-cli:latest" 
  release:
    image: codeberg.org/cyborus/forgejo-cli:latest
    pull: true
    environment:
      TOKEN:
        from_secret: token
    commands:
      - export FORGE_HOST=$(echo $CI_FORGE_URL | sed -E 's_^https?://__')
      - fj --host $CI_FORGE_URL auth add-key $CI_REPO_OWNER $TOKEN
      - fj release --repo $CI_REPO_URL asset create $CI_COMMIT_TAG forgejo-cli-windows.zip
      - fj release --repo $CI_REPO_URL asset create $CI_COMMIT_TAG forgejo-cli-linux.gz
      - fj auth logout $FORGE_HOST

07070100000004000081A4000000000000000000000001681904CF00011DFF000000000000000000000000000000000000001D00000000forgejo-cli-0.3.0/Cargo.lock# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "addr2line"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
dependencies = [
 "gimli",
]

[[package]]
name = "adler2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"

[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
 "memchr",
]

[[package]]
name = "anstream"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
dependencies = [
 "anstyle",
 "anstyle-parse",
 "anstyle-query",
 "anstyle-wincon",
 "colorchoice",
 "is_terminal_polyfill",
 "utf8parse",
]

[[package]]
name = "anstyle"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"

[[package]]
name = "anstyle-parse"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
dependencies = [
 "utf8parse",
]

[[package]]
name = "anstyle-query"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
dependencies = [
 "windows-sys 0.59.0",
]

[[package]]
name = "anstyle-wincon"
version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
dependencies = [
 "anstyle",
 "once_cell",
 "windows-sys 0.59.0",
]

[[package]]
name = "anyhow"
version = "1.0.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"

[[package]]
name = "async-trait"
version = "0.1.88"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"

[[package]]
name = "auth-git2"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55eead120c93036f531829cf9b85830a474e75ce71169680879d28078321ddc"
dependencies = [
 "dirs",
 "git2",
 "terminal-prompt",
]

[[package]]
name = "autocfg"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"

[[package]]
name = "backtrace"
version = "0.3.74"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
dependencies = [
 "addr2line",
 "cfg-if",
 "libc",
 "miniz_oxide",
 "object",
 "rustc-demangle",
 "windows-targets 0.52.6",
]

[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"

[[package]]
name = "base64ct"
version = "1.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"

[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
 "serde",
]

[[package]]
name = "bit-set"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
 "bit-vec",
]

[[package]]
name = "bit-vec"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"

[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"

[[package]]
name = "bitflags"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"

[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
 "generic-array",
]

[[package]]
name = "bon"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65268237be94042665b92034f979c42d431d2fd998b49809543afe3e66abad1c"
dependencies = [
 "bon-macros",
 "rustversion",
]

[[package]]
name = "bon-macros"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "803c95b2ecf650eb10b5f87dda6b9f6a1b758cee53245e2b7b825c9b3803a443"
dependencies = [
 "darling",
 "ident_case",
 "prettyplease",
 "proc-macro2",
 "quote",
 "rustversion",
 "syn",
]

[[package]]
name = "bumpalo"
version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"

[[package]]
name = "bytes"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"

[[package]]
name = "caseless"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
dependencies = [
 "unicode-normalization",
]

[[package]]
name = "cc"
version = "1.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a"
dependencies = [
 "jobserver",
 "libc",
 "shlex",
]

[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"

[[package]]
name = "clap"
version = "4.5.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944"
dependencies = [
 "clap_builder",
 "clap_derive",
]

[[package]]
name = "clap_builder"
version = "4.5.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9"
dependencies = [
 "anstream",
 "anstyle",
 "clap_lex",
 "strsim",
 "terminal_size",
]

[[package]]
name = "clap_complete"
version = "4.5.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c06f5378ea264ad4f82bbc826628b5aad714a75abf6ece087e923010eb937fb6"
dependencies = [
 "clap",
]

[[package]]
name = "clap_derive"
version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
dependencies = [
 "heck",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "clap_lex"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"

[[package]]
name = "colorchoice"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"

[[package]]
name = "comrak"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4f05e73ca9a30af27bebc13600f91fd1651b2ec7d139ca82a89df7ca583af1"
dependencies = [
 "bon",
 "caseless",
 "clap",
 "entities",
 "memchr",
 "shell-words",
 "slug",
 "syntect",
 "typed-arena",
 "unicode_categories",
 "xdg",
]

[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
 "core-foundation-sys",
 "libc",
]

[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"

[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
 "libc",
]

[[package]]
name = "crc32fast"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
dependencies = [
 "cfg-if",
]

[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
 "bitflags 2.9.0",
 "crossterm_winapi",
 "mio",
 "parking_lot",
 "rustix 0.38.44",
 "signal-hook",
 "signal-hook-mio",
 "winapi",
]

[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
 "winapi",
]

[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
 "generic-array",
 "typenum",
]

[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
 "darling_core",
 "darling_macro",
]

[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
 "fnv",
 "ident_case",
 "proc-macro2",
 "quote",
 "strsim",
 "syn",
]

[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
 "darling_core",
 "quote",
 "syn",
]

[[package]]
name = "deranged"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28cfac68e08048ae1883171632c2aef3ebc555621ae56fbccce1cbf22dd7f058"
dependencies = [
 "powerfmt",
 "serde",
]

[[package]]
name = "deunicode"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d"

[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
 "block-buffer",
 "crypto-common",
]

[[package]]
name = "directories"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
dependencies = [
 "dirs-sys",
]

[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
 "dirs-sys",
]

[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
 "libc",
 "option-ext",
 "redox_users",
 "windows-sys 0.59.0",
]

[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
 "cfg-if",
]

[[package]]
name = "entities"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"

[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"

[[package]]
name = "errno"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d"
dependencies = [
 "libc",
 "windows-sys 0.59.0",
]

[[package]]
name = "eyre"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
dependencies = [
 "indenter",
 "once_cell",
]

[[package]]
name = "fancy-regex"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
dependencies = [
 "bit-set",
 "regex",
]

[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"

[[package]]
name = "flate2"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
dependencies = [
 "crc32fast",
 "miniz_oxide",
]

[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"

[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
 "foreign-types-shared",
]

[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"

[[package]]
name = "forgejo-api"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2cb702a4015d636ef784634ece1565e791618a76e0ef8a582a7f9c4b86927d1"
dependencies = [
 "base64ct",
 "bytes",
 "reqwest",
 "serde",
 "serde_json",
 "soft_assert",
 "thiserror 1.0.69",
 "time",
 "tokio",
 "url",
 "zeroize",
]

[[package]]
name = "forgejo-cli"
version = "0.3.0"
dependencies = [
 "auth-git2",
 "base64ct",
 "cfg-if",
 "clap",
 "clap_complete",
 "comrak",
 "crossterm",
 "directories",
 "eyre",
 "forgejo-api",
 "futures",
 "git2",
 "hyper",
 "hyper-util",
 "open",
 "rand",
 "semver",
 "serde",
 "serde_json",
 "sha256",
 "soft_assert",
 "ssh2-config",
 "time",
 "tokio",
 "url",
 "uuid",
]

[[package]]
name = "form_urlencoded"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
dependencies = [
 "percent-encoding",
]

[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
 "futures-channel",
 "futures-core",
 "futures-executor",
 "futures-io",
 "futures-sink",
 "futures-task",
 "futures-util",
]

[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
 "futures-core",
 "futures-sink",
]

[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"

[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
 "futures-core",
 "futures-task",
 "futures-util",
]

[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"

[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"

[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"

[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
 "futures-channel",
 "futures-core",
 "futures-io",
 "futures-macro",
 "futures-sink",
 "futures-task",
 "memchr",
 "pin-project-lite",
 "pin-utils",
 "slab",
]

[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
 "typenum",
 "version_check",
]

[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
 "cfg-if",
 "libc",
 "wasi 0.11.0+wasi-snapshot-preview1",
]

[[package]]
name = "getrandom"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0"
dependencies = [
 "cfg-if",
 "libc",
 "r-efi",
 "wasi 0.14.2+wasi-0.2.4",
]

[[package]]
name = "gimli"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"

[[package]]
name = "git2"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5220b8ba44c68a9a7f7a7659e864dd73692e417ef0211bea133c7b74e031eeb9"
dependencies = [
 "bitflags 2.9.0",
 "libc",
 "libgit2-sys",
 "log",
 "openssl-probe",
 "openssl-sys",
 "url",
]

[[package]]
name = "glob"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"

[[package]]
name = "h2"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2"
dependencies = [
 "atomic-waker",
 "bytes",
 "fnv",
 "futures-core",
 "futures-sink",
 "http",
 "indexmap",
 "slab",
 "tokio",
 "tokio-util",
 "tracing",
]

[[package]]
name = "hashbrown"
version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"

[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"

[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"

[[package]]
name = "http"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
dependencies = [
 "bytes",
 "fnv",
 "itoa",
]

[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
 "bytes",
 "http",
]

[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
 "bytes",
 "futures-core",
 "http",
 "http-body",
 "pin-project-lite",
]

[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"

[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"

[[package]]
name = "hyper"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
dependencies = [
 "bytes",
 "futures-channel",
 "futures-util",
 "h2",
 "http",
 "http-body",
 "httparse",
 "httpdate",
 "itoa",
 "pin-project-lite",
 "smallvec",
 "tokio",
 "want",
]

[[package]]
name = "hyper-rustls"
version = "0.27.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
dependencies = [
 "futures-util",
 "http",
 "hyper",
 "hyper-util",
 "rustls",
 "rustls-pki-types",
 "tokio",
 "tokio-rustls",
 "tower-service",
]

[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
 "bytes",
 "http-body-util",
 "hyper",
 "hyper-util",
 "native-tls",
 "tokio",
 "tokio-native-tls",
 "tower-service",
]

[[package]]
name = "hyper-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497bbc33a26fdd4af9ed9c70d63f61cf56a938375fbb32df34db9b1cd6d643f2"
dependencies = [
 "bytes",
 "futures-channel",
 "futures-util",
 "http",
 "http-body",
 "hyper",
 "libc",
 "pin-project-lite",
 "socket2",
 "tokio",
 "tower-service",
 "tracing",
]

[[package]]
name = "icu_collections"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
dependencies = [
 "displaydoc",
 "yoke",
 "zerofrom",
 "zerovec",
]

[[package]]
name = "icu_locid"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
dependencies = [
 "displaydoc",
 "litemap",
 "tinystr",
 "writeable",
 "zerovec",
]

[[package]]
name = "icu_locid_transform"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
dependencies = [
 "displaydoc",
 "icu_locid",
 "icu_locid_transform_data",
 "icu_provider",
 "tinystr",
 "zerovec",
]

[[package]]
name = "icu_locid_transform_data"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d"

[[package]]
name = "icu_normalizer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
dependencies = [
 "displaydoc",
 "icu_collections",
 "icu_normalizer_data",
 "icu_properties",
 "icu_provider",
 "smallvec",
 "utf16_iter",
 "utf8_iter",
 "write16",
 "zerovec",
]

[[package]]
name = "icu_normalizer_data"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7"

[[package]]
name = "icu_properties"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
dependencies = [
 "displaydoc",
 "icu_collections",
 "icu_locid_transform",
 "icu_properties_data",
 "icu_provider",
 "tinystr",
 "zerovec",
]

[[package]]
name = "icu_properties_data"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2"

[[package]]
name = "icu_provider"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
dependencies = [
 "displaydoc",
 "icu_locid",
 "icu_provider_macros",
 "stable_deref_trait",
 "tinystr",
 "writeable",
 "yoke",
 "zerofrom",
 "zerovec",
]

[[package]]
name = "icu_provider_macros"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"

[[package]]
name = "idna"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
dependencies = [
 "idna_adapter",
 "smallvec",
 "utf8_iter",
]

[[package]]
name = "idna_adapter"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
dependencies = [
 "icu_normalizer",
 "icu_properties",
]

[[package]]
name = "indenter"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"

[[package]]
name = "indexmap"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058"
dependencies = [
 "equivalent",
 "hashbrown",
]

[[package]]
name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"

[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
 "once_cell",
]

[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
 "is-docker",
 "once_cell",
]

[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"

[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"

[[package]]
name = "jobserver"
version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
dependencies = [
 "getrandom 0.3.2",
 "libc",
]

[[package]]
name = "js-sys"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
dependencies = [
 "once_cell",
 "wasm-bindgen",
]

[[package]]
name = "libc"
version = "0.2.171"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"

[[package]]
name = "libgit2-sys"
version = "0.18.1+1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1dcb20f84ffcdd825c7a311ae347cce604a6f084a767dec4a4929829645290e"
dependencies = [
 "cc",
 "libc",
 "libssh2-sys",
 "libz-sys",
 "openssl-sys",
 "pkg-config",
]

[[package]]
name = "libredox"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
 "bitflags 2.9.0",
 "libc",
]

[[package]]
name = "libssh2-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9"
dependencies = [
 "cc",
 "libc",
 "libz-sys",
 "openssl-sys",
 "pkg-config",
 "vcpkg",
]

[[package]]
name = "libz-sys"
version = "1.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d"
dependencies = [
 "cc",
 "libc",
 "pkg-config",
 "vcpkg",
]

[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"

[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"

[[package]]
name = "linux-raw-sys"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413"

[[package]]
name = "litemap"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"

[[package]]
name = "lock_api"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
dependencies = [
 "autocfg",
 "scopeguard",
]

[[package]]
name = "log"
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"

[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"

[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"

[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
 "mime",
 "unicase",
]

[[package]]
name = "miniz_oxide"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5"
dependencies = [
 "adler2",
]

[[package]]
name = "mio"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
 "libc",
 "log",
 "wasi 0.11.0+wasi-snapshot-preview1",
 "windows-sys 0.52.0",
]

[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
 "libc",
 "log",
 "openssl",
 "openssl-probe",
 "openssl-sys",
 "schannel",
 "security-framework",
 "security-framework-sys",
 "tempfile",
]

[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"

[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
 "libc",
]

[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
 "memchr",
]

[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"

[[package]]
name = "onig"
version = "6.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f"
dependencies = [
 "bitflags 1.3.2",
 "libc",
 "once_cell",
 "onig_sys",
]

[[package]]
name = "onig_sys"
version = "69.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7"
dependencies = [
 "cc",
 "pkg-config",
]

[[package]]
name = "open"
version = "5.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95"
dependencies = [
 "is-wsl",
 "libc",
 "pathdiff",
]

[[package]]
name = "openssl"
version = "0.10.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd"
dependencies = [
 "bitflags 2.9.0",
 "cfg-if",
 "foreign-types",
 "libc",
 "once_cell",
 "openssl-macros",
 "openssl-sys",
]

[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"

[[package]]
name = "openssl-sys"
version = "0.9.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd"
dependencies = [
 "cc",
 "libc",
 "pkg-config",
 "vcpkg",
]

[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"

[[package]]
name = "parking_lot"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
dependencies = [
 "lock_api",
 "parking_lot_core",
]

[[package]]
name = "parking_lot_core"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
dependencies = [
 "cfg-if",
 "libc",
 "redox_syscall",
 "smallvec",
 "windows-targets 0.52.6",
]

[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"

[[package]]
name = "percent-encoding"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"

[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"

[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"

[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"

[[package]]
name = "plist"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
dependencies = [
 "base64",
 "indexmap",
 "quick-xml",
 "serde",
 "time",
]

[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"

[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
 "zerocopy",
]

[[package]]
name = "prettyplease"
version = "0.2.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5316f57387668042f561aae71480de936257848f9c43ce528e311d89a07cadeb"
dependencies = [
 "proc-macro2",
 "syn",
]

[[package]]
name = "proc-macro2"
version = "1.0.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "quick-xml"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
dependencies = [
 "memchr",
]

[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
 "proc-macro2",
]

[[package]]
name = "r-efi"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"

[[package]]
name = "rand"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
dependencies = [
 "rand_chacha",
 "rand_core",
 "zerocopy",
]

[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
 "ppv-lite86",
 "rand_core",
]

[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
 "getrandom 0.3.2",
]

[[package]]
name = "redox_syscall"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1"
dependencies = [
 "bitflags 2.9.0",
]

[[package]]
name = "redox_users"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
 "getrandom 0.2.15",
 "libredox",
 "thiserror 2.0.12",
]

[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
 "aho-corasick",
 "memchr",
 "regex-automata",
 "regex-syntax",
]

[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
 "aho-corasick",
 "memchr",
 "regex-syntax",
]

[[package]]
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"

[[package]]
name = "reqwest"
version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
dependencies = [
 "base64",
 "bytes",
 "encoding_rs",
 "futures-core",
 "futures-util",
 "h2",
 "http",
 "http-body",
 "http-body-util",
 "hyper",
 "hyper-rustls",
 "hyper-tls",
 "hyper-util",
 "ipnet",
 "js-sys",
 "log",
 "mime",
 "mime_guess",
 "native-tls",
 "once_cell",
 "percent-encoding",
 "pin-project-lite",
 "rustls-pemfile",
 "serde",
 "serde_json",
 "serde_urlencoded",
 "sync_wrapper",
 "system-configuration",
 "tokio",
 "tokio-native-tls",
 "tower",
 "tower-service",
 "url",
 "wasm-bindgen",
 "wasm-bindgen-futures",
 "web-sys",
 "windows-registry",
]

[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
 "cc",
 "cfg-if",
 "getrandom 0.2.15",
 "libc",
 "untrusted",
 "windows-sys 0.52.0",
]

[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"

[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
 "bitflags 2.9.0",
 "errno",
 "libc",
 "linux-raw-sys 0.4.15",
 "windows-sys 0.59.0",
]

[[package]]
name = "rustix"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf"
dependencies = [
 "bitflags 2.9.0",
 "errno",
 "libc",
 "linux-raw-sys 0.9.3",
 "windows-sys 0.59.0",
]

[[package]]
name = "rustls"
version = "0.23.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "822ee9188ac4ec04a2f0531e55d035fb2de73f18b41a63c70c2712503b6fb13c"
dependencies = [
 "once_cell",
 "rustls-pki-types",
 "rustls-webpki",
 "subtle",
 "zeroize",
]

[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
 "rustls-pki-types",
]

[[package]]
name = "rustls-pki-types"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c"

[[package]]
name = "rustls-webpki"
version = "0.103.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03"
dependencies = [
 "ring",
 "rustls-pki-types",
 "untrusted",
]

[[package]]
name = "rustversion"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2"

[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"

[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
 "winapi-util",
]

[[package]]
name = "schannel"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
dependencies = [
 "windows-sys 0.59.0",
]

[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"

[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
 "bitflags 2.9.0",
 "core-foundation",
 "core-foundation-sys",
 "libc",
 "security-framework-sys",
]

[[package]]
name = "security-framework-sys"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
dependencies = [
 "core-foundation-sys",
 "libc",
]

[[package]]
name = "semver"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"

[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
 "serde_derive",
]

[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "serde_json"
version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [
 "itoa",
 "memchr",
 "ryu",
 "serde",
]

[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
 "form_urlencoded",
 "itoa",
 "ryu",
 "serde",
]

[[package]]
name = "sha2"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
 "cfg-if",
 "cpufeatures",
 "digest",
]

[[package]]
name = "sha256"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f880fc8562bdeb709793f00eb42a2ad0e672c4f883bbe59122b926eca935c8f6"
dependencies = [
 "async-trait",
 "bytes",
 "hex",
 "sha2",
 "tokio",
]

[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"

[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"

[[package]]
name = "signal-hook"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
dependencies = [
 "libc",
 "signal-hook-registry",
]

[[package]]
name = "signal-hook-mio"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
dependencies = [
 "libc",
 "mio",
 "signal-hook",
]

[[package]]
name = "signal-hook-registry"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
dependencies = [
 "libc",
]

[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
 "autocfg",
]

[[package]]
name = "slug"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
dependencies = [
 "deunicode",
 "wasm-bindgen",
]

[[package]]
name = "smallvec"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd"

[[package]]
name = "socket2"
version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef"
dependencies = [
 "libc",
 "windows-sys 0.52.0",
]

[[package]]
name = "soft_assert"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5097ec7ea7218135541ad96348f1441d0c616537dd4ed9c47205920c35d7d97"

[[package]]
name = "ssh2-config"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c8904276fd411f602a6d7e447cccb6f669068badf71359bc4564a5144a866b"
dependencies = [
 "anyhow",
 "bitflags 2.9.0",
 "dirs",
 "git2",
 "glob",
 "log",
 "thiserror 2.0.12",
 "wildmatch",
]

[[package]]
name = "stable_deref_trait"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"

[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"

[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"

[[package]]
name = "syn"
version = "2.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
dependencies = [
 "proc-macro2",
 "quote",
 "unicode-ident",
]

[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
 "futures-core",
]

[[package]]
name = "synstructure"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "syntect"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1"
dependencies = [
 "bincode",
 "bitflags 1.3.2",
 "fancy-regex",
 "flate2",
 "fnv",
 "once_cell",
 "onig",
 "plist",
 "regex-syntax",
 "serde",
 "serde_derive",
 "serde_json",
 "thiserror 1.0.69",
 "walkdir",
 "yaml-rust",
]

[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
 "bitflags 2.9.0",
 "core-foundation",
 "system-configuration-sys",
]

[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
 "core-foundation-sys",
 "libc",
]

[[package]]
name = "tempfile"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf"
dependencies = [
 "fastrand",
 "getrandom 0.3.2",
 "once_cell",
 "rustix 1.0.5",
 "windows-sys 0.59.0",
]

[[package]]
name = "terminal-prompt"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "572818b3472910acbd5dff46a3413715c18e934b071ab2ba464a7b2c2af16376"
dependencies = [
 "libc",
 "winapi",
]

[[package]]
name = "terminal_size"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed"
dependencies = [
 "rustix 1.0.5",
 "windows-sys 0.59.0",
]

[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
 "thiserror-impl 1.0.69",
]

[[package]]
name = "thiserror"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [
 "thiserror-impl 2.0.12",
]

[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "thiserror-impl"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "time"
version = "0.3.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
dependencies = [
 "deranged",
 "itoa",
 "libc",
 "num-conv",
 "num_threads",
 "powerfmt",
 "serde",
 "time-core",
 "time-macros",
]

[[package]]
name = "time-core"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"

[[package]]
name = "time-macros"
version = "0.2.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
dependencies = [
 "num-conv",
 "time-core",
]

[[package]]
name = "tinystr"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
dependencies = [
 "displaydoc",
 "zerovec",
]

[[package]]
name = "tinyvec"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71"
dependencies = [
 "tinyvec_macros",
]

[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"

[[package]]
name = "tokio"
version = "1.44.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a"
dependencies = [
 "backtrace",
 "bytes",
 "libc",
 "mio",
 "parking_lot",
 "pin-project-lite",
 "signal-hook-registry",
 "socket2",
 "tokio-macros",
 "windows-sys 0.52.0",
]

[[package]]
name = "tokio-macros"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
 "native-tls",
 "tokio",
]

[[package]]
name = "tokio-rustls"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
dependencies = [
 "rustls",
 "tokio",
]

[[package]]
name = "tokio-util"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034"
dependencies = [
 "bytes",
 "futures-core",
 "futures-sink",
 "pin-project-lite",
 "tokio",
]

[[package]]
name = "tower"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
dependencies = [
 "futures-core",
 "futures-util",
 "pin-project-lite",
 "sync_wrapper",
 "tokio",
 "tower-layer",
 "tower-service",
]

[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"

[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"

[[package]]
name = "tracing"
version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
 "pin-project-lite",
 "tracing-core",
]

[[package]]
name = "tracing-core"
version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c"
dependencies = [
 "once_cell",
]

[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"

[[package]]
name = "typed-arena"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"

[[package]]
name = "typenum"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"

[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"

[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"

[[package]]
name = "unicode-normalization"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956"
dependencies = [
 "tinyvec",
]

[[package]]
name = "unicode_categories"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"

[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"

[[package]]
name = "url"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
dependencies = [
 "form_urlencoded",
 "idna",
 "percent-encoding",
 "serde",
]

[[package]]
name = "utf16_iter"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"

[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"

[[package]]
name = "uuid"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
dependencies = [
 "getrandom 0.3.2",
]

[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"

[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"

[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
 "same-file",
 "winapi-util",
]

[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
 "try-lock",
]

[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"

[[package]]
name = "wasi"
version = "0.14.2+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
dependencies = [
 "wit-bindgen-rt",
]

[[package]]
name = "wasm-bindgen"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
dependencies = [
 "cfg-if",
 "once_cell",
 "rustversion",
 "wasm-bindgen-macro",
]

[[package]]
name = "wasm-bindgen-backend"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
dependencies = [
 "bumpalo",
 "log",
 "proc-macro2",
 "quote",
 "syn",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-futures"
version = "0.4.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
dependencies = [
 "cfg-if",
 "js-sys",
 "once_cell",
 "wasm-bindgen",
 "web-sys",
]

[[package]]
name = "wasm-bindgen-macro"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
dependencies = [
 "quote",
 "wasm-bindgen-macro-support",
]

[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "wasm-bindgen-backend",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-shared"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "web-sys"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
dependencies = [
 "js-sys",
 "wasm-bindgen",
]

[[package]]
name = "wildmatch"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd"

[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
 "winapi-i686-pc-windows-gnu",
 "winapi-x86_64-pc-windows-gnu",
]

[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"

[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
 "windows-sys 0.59.0",
]

[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

[[package]]
name = "windows-link"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"

[[package]]
name = "windows-registry"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [
 "windows-result",
 "windows-strings",
 "windows-targets 0.53.0",
]

[[package]]
name = "windows-result"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fd11a4fd95df68efcfee5f44a294fe71b8bc6a91993e2791938abcc712252"
dependencies = [
 "windows-link",
]

[[package]]
name = "windows-strings"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319"
dependencies = [
 "windows-link",
]

[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
 "windows-targets 0.52.6",
]

[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
 "windows-targets 0.52.6",
]

[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
 "windows_aarch64_gnullvm 0.52.6",
 "windows_aarch64_msvc 0.52.6",
 "windows_i686_gnu 0.52.6",
 "windows_i686_gnullvm 0.52.6",
 "windows_i686_msvc 0.52.6",
 "windows_x86_64_gnu 0.52.6",
 "windows_x86_64_gnullvm 0.52.6",
 "windows_x86_64_msvc 0.52.6",
]

[[package]]
name = "windows-targets"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b"
dependencies = [
 "windows_aarch64_gnullvm 0.53.0",
 "windows_aarch64_msvc 0.53.0",
 "windows_i686_gnu 0.53.0",
 "windows_i686_gnullvm 0.53.0",
 "windows_i686_msvc 0.53.0",
 "windows_x86_64_gnu 0.53.0",
 "windows_x86_64_gnullvm 0.53.0",
 "windows_x86_64_msvc 0.53.0",
]

[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"

[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"

[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"

[[package]]
name = "windows_aarch64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"

[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"

[[package]]
name = "windows_i686_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"

[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"

[[package]]
name = "windows_i686_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"

[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"

[[package]]
name = "windows_i686_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"

[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"

[[package]]
name = "windows_x86_64_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"

[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"

[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"

[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

[[package]]
name = "windows_x86_64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"

[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
 "bitflags 2.9.0",
]

[[package]]
name = "write16"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"

[[package]]
name = "writeable"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"

[[package]]
name = "xdg"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546"

[[package]]
name = "yaml-rust"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
dependencies = [
 "linked-hash-map",
]

[[package]]
name = "yoke"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
dependencies = [
 "serde",
 "stable_deref_trait",
 "yoke-derive",
 "zerofrom",
]

[[package]]
name = "yoke-derive"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "synstructure",
]

[[package]]
name = "zerocopy"
version = "0.8.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879"
dependencies = [
 "zerocopy-derive",
]

[[package]]
name = "zerocopy-derive"
version = "0.8.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "zerofrom"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
dependencies = [
 "zerofrom-derive",
]

[[package]]
name = "zerofrom-derive"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "synstructure",
]

[[package]]
name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"

[[package]]
name = "zerovec"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
dependencies = [
 "yoke",
 "zerofrom",
 "zerovec-derive",
]

[[package]]
name = "zerovec-derive"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]
07070100000005000081A4000000000000000000000001681904CF0000054A000000000000000000000000000000000000001D00000000forgejo-cli-0.3.0/Cargo.toml[package]
name = "forgejo-cli"
version = "0.3.0"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://codeberg.org/Cyborus/forgejo-cli/"
description = "CLI tool for Forgejo"
keywords = ["cli", "forgejo"]
categories = ["command-line-utilities", "development-tools"]

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[[bin]]
name = "fj"
path = "src/main.rs"

[features]
update-check = ["dep:semver"]

[dependencies]
auth-git2 = "0.5.7"
base64ct = { version = "1.7.3", features = ["std"] }
cfg-if = "1.0.0"
clap = { version = "4.5.34", features = ["derive"] }
clap_complete = "4.5.47"
comrak = "0.37.0"
crossterm = "0.28.1"
directories = "6.0.0"
eyre = "0.6.12"
forgejo-api = "0.7.0"
futures = "0.3.31"
git2 = "0.20.1"
hyper = "1.6.0"
hyper-util = { version = "0.1.11", features = ["tokio", "server", "http1", "http2"] }
open = "5.3.2"
rand = "0.9.0"
semver = { version = "1.0.26", optional = true }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sha256 = "1.6.0"
soft_assert = "0.1.1"
ssh2-config = "0.5.4"
time = { version = "0.3.41", features = ["formatting", "local-offset", "macros"] }
tokio = { version = "1.44.1", features = ["full"] }
url = "2.5.4"
uuid = { version = "1.16.0", features = ["v4"] }

[build-dependencies]
git2 = "0.20.1"
uuid = "1.16.0"

07070100000006000081A4000000000000000000000001681904CF0000008E000000000000000000000000000000000000001D00000000forgejo-cli-0.3.0/DockerfileFROM debian:12
RUN apt update
RUN apt install libssl-dev ca-certificates -y
COPY target/x86_64-unknown-linux-gnu/release/fj /usr/local/bin/fj
07070100000007000081A4000000000000000000000001681904CF00002C5D000000000000000000000000000000000000002100000000forgejo-cli-0.3.0/LICENSE-APACHE                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
07070100000008000081A4000000000000000000000001681904CF0000042D000000000000000000000000000000000000001E00000000forgejo-cli-0.3.0/LICENSE-MITMIT License

Copyright (c) [year] [fullname]

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
07070100000009000081A4000000000000000000000001681904CF00000B39000000000000000000000000000000000000001C00000000forgejo-cli-0.3.0/README.md# forgejo-cli

CLI tool for interacting with Forgejo

[Matrix Chat](https://matrix.to/#/#forgejo-cli:cartoon-aa.xyz)

## Installation

### Pre-built

Pre-built binaries are available for `x86_64` Windows and Linux (GNU) on the
[releases tab](https://codeberg.org/Cyborus/forgejo-cli/releases/latest).

### From source

Install with `cargo install`

```
# Latest version
cargo install forgejo-cli
# From `main`
cargo install --git https://codeberg.org/Cyborus/forgejo-cli.git --branch main
```

### Fedora

An **unofficial** [COPR repository](https://copr.fedorainfracloud.org/coprs/lihaohong/forgejo-cli/) is available. Fedora users can install forgejo-cli with
```sh
sudo dnf copr enable lihaohong/forgejo-cli
sudo dnf install forgejo-cli
```

If you see an error about copr not being an available command, run `sudo dnf install dnf-plugins-core`.

### Nix

A Nix flake is included in this repository that you may use. You could install it into your Nix
profile, for example:
```
nix profile install git+https://codeberg.org/Cyborus/forgejo-cli
```
...or include it in the flake inputs of your NixOS system:
```nix
{
  inputs = {
    # ...
    forgejo-cli.url = "git+https://codeberg.org/Cyborus/forgejo-cli";
  };
  # ...
}
```


### OCI Container

`forgejo-cli` is available as an OCI container for use in CI, at
`codeberg.org/cyborus/forgejo-cli:latest`

## Usage

### Instance-specific aliases

While you can just use the `fj` binary directly, it can be useful to alias it
with the `--host` flag set, to create shorthands for certain instances.

```bash
# For example, a `cb` command for interacting with codeberg
alias cb="fj --host codeberg.org"
# Or code.forgejo.org
alias cfj="fj --host code.forgejo.org"
# Or any other instance you want!
# And the alias name can be whatever, as long as the `--host` flag is set.
```

Now, when you reference a repository such as `forgejo/forgejo`, it will
implicitly get it from whichever alias you used!

```
$ cb repo info forgejo/forgejo
forgejo/forgejo
> Beyond coding. We forge.

Primary language is Go
# etc...
```

When using `fj` directly, you'd have to use a URL to access it.

```
$ fj repo info codeberg.org/forgejo/forgejo
forgejo/forgejo
> Beyond coding. We forge.

Primary language is Go
# etc...

# Notice the "cfj", trying to access code.forgejo.org, still works when you specify Codeberg in the repository name!
$ cfj repo info codeberg.org/forgejo/forgejo
forgejo/forgejo
> Beyond coding. We forge.

Primary language is Go
# etc...
```

## Licensing

This project is licensed under either
[Apache License Version 2.0](LICENSE-APACHE) or [MIT License](LICENSE-MIT)
at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.

0707010000000A000081A4000000000000000000000001681904CF00000643000000000000000000000000000000000000001B00000000forgejo-cli-0.3.0/build.rsuse std::fmt::Write;

fn main() {
    println!(
        "cargo:rustc-env=BUILD_TARGET={}",
        std::env::var("TARGET").unwrap()
    );

    set_oauth_ids();
}

fn set_oauth_ids() {
    let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("always present"));

    println!("cargo::rerun-if-env-changed=BUILTIN_CLIENT_IDS");
    let mut client_info_branches = String::new();
    if let Ok(oauth_supported) = std::env::var("BUILTIN_CLIENT_IDS") {
        if oauth_supported.contains('"') {
            println!("cargo::error=BUILTIN_CLIENT_IDS contains a quote character. If you did not set it yourself, this could mean it is malicious and trying to inject code.");
            return;
        }
        for info in oauth_supported.split(",") {
            let Some((domain, id)) = info.split_once(" ") else {
                println!("cargo::warning=BUILTIN_CLIENT_IDS is set improperly");
                continue;
            };
            if uuid::Uuid::parse_str(&id).is_err() {
                println!("cargo::warning=client id for {domain} is invalid. It should be a UUID");
                continue;
            }
            writeln!(&mut client_info_branches, "\"{domain}\" => Some(\"{id}\"),")
                .expect("writing to string can't fail");
        }
    }
    let oauth_match = format!(
        "match host {{
        {client_info_branches}
        _ => None,
    }}"
    );
    let oauth_src_file_path = out_dir.join("oauth_client_info.rs");
    std::fs::write(oauth_src_file_path, oauth_match)
        .expect("Failed to write oauth client info file");
}
0707010000000B000081A4000000000000000000000001681904CF000005C9000000000000000000000000000000000000001D00000000forgejo-cli-0.3.0/flake.lock{
  "nodes": {
    "nixpkgs": {
      "locked": {
        "lastModified": 1743259260,
        "narHash": "sha256-ArWLUgRm1tKHiqlhnymyVqi5kLNCK5ghvm06mfCl4QY=",
        "owner": "NixOS",
        "repo": "nixpkgs",
        "rev": "eb0e0f21f15c559d2ac7633dc81d079d1caf5f5f",
        "type": "github"
      },
      "original": {
        "owner": "NixOS",
        "ref": "nixpkgs-unstable",
        "repo": "nixpkgs",
        "type": "github"
      }
    },
    "root": {
      "inputs": {
        "nixpkgs": "nixpkgs",
        "utils": "utils"
      }
    },
    "systems": {
      "locked": {
        "lastModified": 1681028828,
        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
        "owner": "nix-systems",
        "repo": "default",
        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
        "type": "github"
      },
      "original": {
        "owner": "nix-systems",
        "repo": "default",
        "type": "github"
      }
    },
    "utils": {
      "inputs": {
        "systems": "systems"
      },
      "locked": {
        "lastModified": 1731533236,
        "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
        "owner": "numtide",
        "repo": "flake-utils",
        "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
        "type": "github"
      },
      "original": {
        "owner": "numtide",
        "repo": "flake-utils",
        "type": "github"
      }
    }
  },
  "root": "root",
  "version": 7
}
0707010000000C000081A4000000000000000000000001681904CF000004C4000000000000000000000000000000000000001C00000000forgejo-cli-0.3.0/flake.nix{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
    utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, utils }: utils.lib.eachDefaultSystem (system:
    let
      pkgs = import nixpkgs { inherit system; };
    in
    rec {
      packages.forgejo-cli = pkgs.rustPlatform.buildRustPackage {
        pname = "forgejo-cli";
        version = "0.3.0";
        src = pkgs.lib.cleanSource ./.;

        cargoLock.lockFile = ./Cargo.lock;

        nativeBuildInputs = with pkgs; [ pkg-config ];
        buildInputs = with pkgs; [ openssl ];

        meta = with pkgs.lib; {
          description = "CLI tool for Forgejo";
          homepage = "https://codeberg.org/Cyborus/forgejo-cli/";
          license = with licenses; [ asl20 /* or */ mit ];
        };

        env = {
          BUILD_TYPE = "flake";
        };
      };

      packages.default = packages.forgejo-cli;

      devShells.default = pkgs.mkShell {
        inputsFrom = [ packages.default ];

        nativeBuildInputs = with pkgs; [
          cargo
          rustc
        ];

        # Required for rust-analyzer to work
        RUST_SRC_PATH = "${pkgs.rustPlatform.rustcSrc}/library";
      };
    });
}

0707010000000D000041ED000000000000000000000002681904CF00000000000000000000000000000000000000000000001600000000forgejo-cli-0.3.0/src0707010000000E000081A4000000000000000000000001681904CF00003224000000000000000000000000000000000000002100000000forgejo-cli-0.3.0/src/actions.rsuse std::{collections::BTreeMap, fmt::Display};

use clap::{Args, Subcommand};
use eyre::{bail, OptionExt};
use forgejo_api::{
    structs::{
        ActionVariable, CreateOrUpdateSecretOption, CreateVariableOption,
        GetRepoVariablesListQuery, RepoListActionsSecretsQuery, UpdateVariableOption,
    },
    Forgejo, ForgejoError,
};
use hyper::StatusCode;
use time::Duration;

use crate::{
    repo::{RepoArg, RepoInfo, RepoName},
    SpecialRender,
};

#[derive(Args, Clone, Debug)]
pub struct ActionsCommand {
    /// The local git remote that points to the repo to operate on.
    #[clap(long, short = 'R')]
    remote: Option<String>,

    #[clap(long, short = 'r')]
    repo: Option<RepoArg>,

    #[clap(subcommand)]
    command: ActionsSubcommand,
}

#[derive(Subcommand, Clone, Debug)]
pub enum ActionsSubcommand {
    /// List the tasks on a repo
    Tasks {
        /// The page to show. One page always includes up to 20 tasks.
        #[clap(long, short, default_value = "1")]
        page: u32,
    },

    /// List and manage variables
    Variables {
        #[clap(subcommand)]
        command: ActionsVariablesSubcommmand,
    },

    Secrets {
        #[clap(subcommand)]
        command: ActionsSecretsSubcommmand,
    },

    /// Dispatch a workflow
    Dispatch {
        /// Name of the workflow to dispatch
        name: String,

        /// Git revision to dispatch the workflow on
        r#ref: String,

        #[clap(long, short = 'I', value_parser = parse_dispatch_kvs)]
        inputs: Vec<(String, String)>,
    },
}

#[derive(Subcommand, Clone, Debug)]
pub enum ActionsVariablesSubcommmand {
    /// List variables
    List {
        /// Also print owner_id and repo_id
        #[clap(long, short)]
        verbose: bool,
    },

    /// Create a new variable
    Create {
        /// The name of the new variable
        name: String,

        /// The data to save into the variable. Omit to invoke editor.
        data: Option<String>,

        /// Override existing variables
        #[clap(long, short)]
        force: bool,
    },

    Delete {
        /// The variable to delete
        name: String,
    },
}

#[derive(Subcommand, Clone, Debug)]
pub enum ActionsSecretsSubcommmand {
    /// List secrets
    List,

    /// Create a new secret
    Create {
        /// The name of the new secret
        name: String,

        /// The data to save into the secret.
        data: String,
    },

    Delete {
        /// The secret to delete
        name: String,
    },
}

impl ActionsCommand {
    pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
        let repo =
            RepoInfo::get_current(host_name, self.repo.as_ref(), self.remote.as_deref(), keys)?;

        let api = keys.get_api(repo.host_url()).await?;
        let repo = repo
            .name()
            .ok_or_eyre("can't figure what repo to access, try specifying with `--repo`")?;
        match self.command {
            ActionsSubcommand::Tasks { page } => view_tasks(repo, &api, page).await?,

            ActionsSubcommand::Variables { command } => match command {
                ActionsVariablesSubcommmand::List { verbose } => {
                    list_variables(repo, &api, verbose).await?
                }
                ActionsVariablesSubcommmand::Create { name, data, force } => {
                    create_variable(repo, &api, name, data, force).await?
                }
                ActionsVariablesSubcommmand::Delete { name } => {
                    delete_variable(repo, &api, name).await?
                }
            },

            ActionsSubcommand::Secrets { command } => match command {
                ActionsSecretsSubcommmand::List => list_secrets(repo, &api).await?,
                ActionsSecretsSubcommmand::Create { name, data } => {
                    create_secret(repo, &api, name, data).await?
                }
                ActionsSecretsSubcommmand::Delete { name } => {
                    delete_secret(repo, &api, name).await?
                }
            },

            ActionsSubcommand::Dispatch {
                name,
                r#ref,
                inputs,
            } => dispatch(repo, &api, name, r#ref, inputs.into_iter().collect()).await?,
        }

        Ok(())
    }
}

async fn view_tasks(repo: &RepoName, api: &Forgejo, page: u32) -> eyre::Result<()> {
    // We don't iterate this to collect all tasks (not just the ones on the first page) like the
    // issue search subcommand will do, because it's unlikely someone wants to see *all* tasks.
    let res = api
        .list_action_tasks(
            repo.owner(),
            repo.name(),
            forgejo_api::structs::ListActionTasksQuery {
                page: Some(page),
                limit: Some(20),
            },
        )
        .await?;

    if res.total_count == Some(1) {
        println!("1 task");
    } else {
        println!("{} tasks", res.total_count.unwrap_or(0));
    }

    let SpecialRender {
        fancy,
        reset,

        bold,
        bright_green,
        light_grey,
        bright_red,
        yellow,
        bright_blue,
        ..
    } = *crate::special_render();

    for task in res.workflow_runs.unwrap_or_default() {
        let task_sym = match task.status.as_deref() {
            // Don't use symbols when we're not in fancy mode.
            x if !fancy => x.unwrap_or("?"),

            // See: https://codeberg.org/forgejo/forgejo/src/commit/5380f23daba969057d9afc53c3dc746eca95188c/models/actions/status.go#L26
            Some("success") => &format!("{bright_green}✓{reset}"),
            Some("cancelled") => &format!("{light_grey}!{reset}"),
            Some("failure") => &format!("{bright_red}×{reset}"),
            Some("waiting") => &format!("{light_grey}{reset}"),
            Some("running") => &format!("{yellow}●{reset}"),
            Some("skipped") => &format!("{light_grey}{reset}"),
            Some("blocked") => &format!("{bright_red}{reset}"),
            Some(x) => x,
            None => "?",
        };

        let sha = task.head_sha.unwrap_or_default();
        let sha = if sha.len() > 10 { &sha[0..10] } else { &sha };

        let time = if let (Some(end), Some(start)) = (task.updated_at, task.run_started_at) {
            end - start
        } else {
            Duration::default()
        };

        println!(
            "#{bold}{}{reset} ({bright_blue}{}{reset}) {} {} {bright_green}{}{reset} ({}): {yellow}{}{reset}",
            task.run_number.unwrap_or(0),
            sha,
            task_sym,
            task.name.unwrap_or_default(),
            time,
            task.event.unwrap_or_default(),
            task.display_title.unwrap_or_default(),
        );
    }

    Ok(())
}

async fn list_variables(repo: &RepoName, api: &Forgejo, verbose: bool) -> eyre::Result<()> {
    let per_page = 64;
    let mut variables = vec![];

    for page in 1.. {
        let (_headers, vars) = api
            .get_repo_variables_list(
                repo.owner(),
                repo.name(),
                GetRepoVariablesListQuery {
                    page: Some(page),
                    limit: Some(per_page),
                },
            )
            .await?;

        let done = vars.len() < per_page as usize;
        variables.extend(vars.into_iter());
        if done {
            break;
        }
    }

    for var in variables {
        println!("{}", DisplayActionVariable::new(var, verbose)?);
    }

    Ok(())
}

async fn create_variable(
    repo: &RepoName,
    api: &Forgejo,
    name: String,
    data: Option<String>,
    force: bool,
) -> eyre::Result<()> {
    let mut data = if let Some(data) = data {
        data
    } else {
        let mut data = String::new();
        crate::editor(&mut data, Some("variable_content.txt")).await?;
        data
    };

    match api
        .create_repo_variable(
            repo.owner(),
            repo.name(),
            &name,
            CreateVariableOption {
                // If we don't have force enabled, we will not need the data again to (potentially)
                // make another request. To avoid a clone in this case, we take the string here,
                // replacing it with an empty one.
                value: if force {
                    data.clone()
                } else {
                    std::mem::take(&mut data)
                },
            },
        )
        .await
    {
        Err(ForgejoError::ApiError(StatusCode::CONFLICT, _)) => {
            if !force {
                bail!("variable already exists, pass --force to replace it.");
            }

            eprintln!("variable already exists, updating.");
            api.update_repo_variable(
                repo.owner(),
                repo.name(),
                &name,
                UpdateVariableOption {
                    name: None,
                    value: data,
                },
            )
            .await?;
        }
        Err(e) => return Err(e.into()),
        Ok(()) => {}
    }

    Ok(())
}

async fn delete_variable(repo: &RepoName, api: &Forgejo, name: String) -> eyre::Result<()> {
    let var = api
        .delete_repo_variable(repo.owner(), repo.name(), &name)
        .await?;

    if let Some(var) = var {
        println!("Deleted: {}", DisplayActionVariable::new(var, false)?);
    } else {
        println!("Variable {name} deleted.");
    }

    Ok(())
}

async fn list_secrets(repo: &RepoName, api: &Forgejo) -> eyre::Result<()> {
    let per_page = 64;
    let mut secrets = vec![];

    for page in 1.. {
        let (_headers, page_secrets) = api
            .repo_list_actions_secrets(
                repo.owner(),
                repo.name(),
                RepoListActionsSecretsQuery {
                    page: Some(page),
                    limit: Some(per_page),
                },
            )
            .await?;

        let done = page_secrets.len() < per_page as usize;
        secrets.extend(page_secrets.into_iter());
        if done {
            break;
        }
    }

    for secret in secrets {
        println!(
            "({}) {}",
            crate::DisplayOptional(secret.created_at, "?"),
            crate::DisplayOptional(secret.name, "?")
        );
    }

    Ok(())
}

async fn create_secret(
    repo: &RepoName,
    api: &Forgejo,
    name: String,
    data: String,
) -> eyre::Result<()> {
    api.update_repo_secret(
        repo.owner(),
        repo.name(),
        &name,
        CreateOrUpdateSecretOption { data },
    )
    .await?;

    Ok(())
}

async fn delete_secret(repo: &RepoName, api: &Forgejo, name: String) -> eyre::Result<()> {
    api.delete_repo_secret(repo.owner(), repo.name(), &name)
        .await?;

    Ok(())
}

async fn dispatch(
    repo: &RepoName,
    api: &Forgejo,
    name: String,
    r#ref: String,
    inputs: BTreeMap<String, String>,
) -> eyre::Result<()> {
    let n_inputs = inputs.len();
    api.dispatch_workflow(
        repo.owner(),
        repo.name(),
        &name,
        forgejo_api::structs::DispatchWorkflowOption {
            inputs: Some(inputs),
            r#ref: r#ref.clone(),
        },
    )
    .await?;

    println!("Dispatched workflow {name} in {ref} with {n_inputs} input(s).");

    Ok(())
}

struct DisplayActionVariable {
    name: String,
    data: String,
    owner_id: Option<i64>,
    repo_id: Option<i64>,
    verbose: bool,
}

impl DisplayActionVariable {
    fn new(value: ActionVariable, verbose: bool) -> eyre::Result<Self> {
        Ok(Self {
            name: value
                .name
                .ok_or_eyre("Server returned ActionVariable without name?!")?,
            // The API usually (always?) returns Some("") here. The page on variables also notes
            // that their value cannot be read by other means than being passed to a CI job.
            data: value.data.unwrap_or_default(),
            owner_id: value.owner_id,
            repo_id: value.repo_id,
            verbose,
        })
    }
}

impl Display for DisplayActionVariable {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if self.verbose {
            write!(
                f,
                "({}, {}) ",
                crate::DisplayOptional(self.owner_id, "?"),
                crate::DisplayOptional(self.repo_id, "?"),
            )?;
        }

        write!(f, "{}", self.name)?;

        if !self.data.is_empty() {
            write!(f, " = {}", self.data)?;
        }

        Ok(())
    }
}

fn parse_dispatch_kvs(s: &str) -> eyre::Result<(String, String)> {
    let eq_idx = s
        .find('=')
        .ok_or_eyre("Input argument does not contain a '=' character!")?;

    Ok((s[..eq_idx].to_string(), s[eq_idx + 1..].to_string()))
}
0707010000000F000081A4000000000000000000000001681904CF000033C4000000000000000000000000000000000000001E00000000forgejo-cli-0.3.0/src/auth.rsuse clap::Subcommand;
use eyre::OptionExt;

use std::collections::BTreeMap;

#[derive(Subcommand, Clone, Debug)]
pub enum AuthCommand {
    /// Log in to an instance.
    ///
    /// Opens an auth page in your browser
    Login,
    /// Deletes login info for an instance
    Logout {
        host: String,
    },
    /// Add an application token for an instance
    ///
    /// Use this if `fj auth login` doesn't work
    AddKey {
        /// The user that the key is associated with
        user: String,
        /// The key to add. If not present, the key will be read in from stdin.
        key: Option<String>,
    },
    UseSsh {
        use_ssh: Option<bool>,
    },
    /// List all instances you're currently logged into
    List,
}

impl AuthCommand {
    pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
        match self {
            AuthCommand::Login => {
                let repo_info = crate::repo::RepoInfo::get_current(host_name, None, None, &keys)?;
                let host_url = repo_info.host_url();
                let client_info = get_client_info_for(host_url).await?;
                if let Some(client_id) = &client_info {
                    oauth_login(keys, host_url, client_id).await?;
                } else {
                    let host_domain = host_url.host_str().ok_or_eyre("invalid host")?;
                    let host_path = host_url.path().strip_suffix("/").unwrap_or(host_url.path());
                    let applications_url =
                        format!("https://{host_domain}{host_path}/user/settings/applications");

                    println!("Your installation of fj doesn't support `login` for {host_domain}{host_path}");
                    println!();
                    println!("Please visit {applications_url}");
                    println!("to create a token, and use it to log in with `fj auth add-key`");
                }
            }
            AuthCommand::Logout { host } => {
                let info_opt = keys.hosts.remove(&host);
                if let Some(info) = info_opt {
                    eprintln!("signed out of {}@{}", &info.username(), host);
                } else {
                    eprintln!("already not signed in to {host}");
                }
            }
            AuthCommand::AddKey { user, key } => {
                let repo_info = crate::repo::RepoInfo::get_current(host_name, None, None, &keys)?;
                let host_url = repo_info.host_url();
                let key = match key {
                    Some(key) => key,
                    None => crate::readline("new key: ").await?.trim().to_string(),
                };
                let host = crate::host_with_port(&host_url);
                if !keys.hosts.contains_key(host) {
                    let mut login = crate::keys::LoginInfo::Application {
                        name: user,
                        token: key,
                    };
                    add_ssh_alias(&mut login, host_url, keys).await;
                    keys.hosts.insert(host.to_owned(), login);
                } else {
                    println!("key for {host} already exists");
                }
            }
            AuthCommand::UseSsh { use_ssh } => {
                let repo_info = crate::repo::RepoInfo::get_current(host_name, None, None, &keys)?;
                let host = crate::host_with_port(&repo_info.host_url());
                if !keys.hosts.contains_key(host) {
                    println!("not logged in to {host}");
                } else {
                    if use_ssh.unwrap_or(true) {
                        let already_present = keys.default_ssh.insert(host.to_string());
                        if already_present {
                            println!("now will use SSH for {host} by default");
                        } else {
                            println!("already using SSH for {host} by default");
                        }
                    } else {
                        let was_present = keys.default_ssh.remove(host);
                        if was_present {
                            println!("will no longer use SSH for {host} by default");
                        } else {
                            println!("already not using SSH for {host} by default");
                        }
                    }
                }
            }
            AuthCommand::List => {
                if keys.hosts.is_empty() {
                    println!("No logins.");
                }
                for (host_url, login_info) in &keys.hosts {
                    println!("{}@{}", login_info.username(), host_url);
                }
            }
        }
        Ok(())
    }
}

pub async fn get_client_info_for(url: &url::Url) -> eyre::Result<Option<String>> {
    let host = crate::host_with_port_and_path(url);
    let host = host.strip_suffix("/").unwrap_or(host);
    if let Some(dirs) = directories::ProjectDirs::from("", "Cyborus", "forgejo-cli") {
        let client_info_path = dirs.config_dir().join("client_ids");
        if let Ok(file) = tokio::fs::read_to_string(client_info_path).await {
            let ids = parse_client_info_file(&file)?;
            if let Some(id) = ids.get(host) {
                return Ok(Some(id.to_string()));
            }
        }
    }

    #[cfg(unix)]
    {
        let global_client_info_path = "/etc/fj/client_ids";
        if let Ok(file) = tokio::fs::read_to_string(global_client_info_path).await {
            let ids = parse_client_info_file(&file)?;
            if let Some(id) = ids.get(host) {
                return Ok(Some(id.to_string()));
            }
        }
    }

    if option_env!("BUILTIN_CLIENT_IDS").is_some() {
        let id: Option<&'static str> = include!(concat!(env!("OUT_DIR"), "/oauth_client_info.rs"));
        if let Some(id) = id {
            return Ok(Some(id.into()));
        }
    }

    Ok(None)
}

fn parse_client_info_file(file: &str) -> eyre::Result<BTreeMap<&str, &str>> {
    file.lines()
        .map(|s| s.split_once("#").map(|s| s.0).unwrap_or(s).trim())
        .enumerate()
        .filter(|(_, s)| !s.is_empty())
        .map(|(line_num, s)| {
            let mut iter = s.split_whitespace();
            let host = iter.next().expect("can't fail, empty lines filtered");
            let client_id = iter
                .next()
                .ok_or_else(|| eyre::eyre!("missing client id on line {}", line_num + 1))?;
            Ok::<_, eyre::Error>((host, client_id))
        })
        .collect::<Result<BTreeMap<&str, &str>, _>>()
}

//pub fn get_client_info_for(url: &url::Url) -> Option<&'static str> {
//    let host = crate::host_with_port_and_path(url);
//    let host = host.strip_suffix("/").unwrap_or(host);
//    include!(concat!(env!("OUT_DIR"), "/oauth_client_info.rs"))
//}

async fn oauth_login(
    keys: &mut crate::KeyInfo,
    host: &url::Url,
    client_id: &str,
) -> eyre::Result<()> {
    use base64ct::Encoding;
    use rand::{distr::Alphanumeric, prelude::*};

    let mut rng = rand::rng();

    let state = (0..32)
        .map(|_| rng.sample(Alphanumeric) as char)
        .collect::<String>();
    let code_verifier = (0..43)
        .map(|_| rng.sample(Alphanumeric) as char)
        .collect::<String>();
    let code_challenge =
        base64ct::Base64Url::encode_string(sha256::digest(&code_verifier).as_bytes());

    let mut auth_url = host.clone();
    auth_url
        .path_segments_mut()
        .map_err(|_| eyre::eyre!("invalid url"))?
        .extend(["login", "oauth", "authorize"]);
    auth_url.query_pairs_mut().extend_pairs([
        ("client_id", client_id),
        ("redirect_uri", "http://127.0.0.1:26218/"),
        ("response_type", "code"),
        ("code_challenge_method", "S256"),
        ("code_challenge", &code_challenge),
        ("state", &state),
    ]);
    open::that(auth_url.as_str()).unwrap();

    let (handle, mut rx) = auth_server();
    let res = rx.recv().await.unwrap();
    handle.abort();
    let code = match res {
        Ok(Some((code, returned_state))) => {
            if returned_state == state {
                code
            } else {
                eyre::bail!("returned with invalid state");
            }
        }
        Ok(None) => {
            println!("Login canceled");
            return Ok(());
        }
        Err(e) => {
            eyre::bail!("Failed to authenticate: {e}");
        }
    };

    let api = forgejo_api::Forgejo::with_user_agent(
        forgejo_api::Auth::None,
        host.clone(),
        crate::USER_AGENT,
    )?;
    let request = forgejo_api::structs::OAuthTokenRequest::Public {
        client_id,
        code_verifier: &code_verifier,
        code: &code,
        redirect_uri: url::Url::parse("http://127.0.0.1:26218/").unwrap(),
    };
    let response = api.oauth_get_access_token(request).await?;

    let api = forgejo_api::Forgejo::with_user_agent(
        forgejo_api::Auth::OAuth2(&response.access_token),
        host.clone(),
        crate::USER_AGENT,
    )?;
    let current_user = api.user_get_current().await?;
    let name = current_user
        .login
        .ok_or_eyre("user does not have login name")?;

    // A minute less, in case any weirdness happens at the exact moment it
    // expires. Better to refresh slightly too soon than slightly too late.
    let expires_in = std::time::Duration::from_secs(response.expires_in.saturating_sub(60) as u64);
    let expires_at = time::OffsetDateTime::now_utc() + expires_in;
    let mut login_info = crate::keys::LoginInfo::OAuth {
        name,
        token: response.access_token,
        refresh_token: response.refresh_token,
        expires_at,
    };
    add_ssh_alias(&mut login_info, host, keys).await;
    let domain = crate::host_with_port(&host);
    keys.hosts.insert(domain.to_owned(), login_info);

    Ok(())
}

use tokio::{sync::mpsc::Receiver, task::JoinHandle};

fn auth_server() -> (
    JoinHandle<eyre::Result<()>>,
    Receiver<Result<Option<(String, String)>, String>>,
) {
    let addr: std::net::SocketAddr = ([127, 0, 0, 1], 26218).into();
    let (tx, rx) = tokio::sync::mpsc::channel(1);
    let tx = std::sync::Arc::new(tx);
    let handle = tokio::spawn(async move {
        let listener = tokio::net::TcpListener::bind(addr).await?;
        let server =
            hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new());
        let svc = hyper::service::service_fn(|req: hyper::Request<hyper::body::Incoming>| {
            let tx = std::sync::Arc::clone(&tx);
            async move {
                let mut code = None;
                let mut state = None;
                let mut error_description = None;
                if let Some(query) = req.uri().query() {
                    for item in query.split("&") {
                        let (key, value) = item.split_once("=").unwrap_or((item, ""));
                        match key {
                            "code" => code = Some(value),
                            "state" => state = Some(value),
                            "error_description" => error_description = Some(value),
                            _ => eprintln!("unknown key {key} {value}"),
                        }
                    }
                }
                let (response, message) = match (code, state, error_description) {
                    (_, _, Some(error)) => (Err(error.to_owned()), "Failed to authenticate"),
                    (Some(code), Some(state), None) => (
                        Ok(Some((code.to_owned(), state.to_owned()))),
                        "Authenticated! Close this tab and head back to your terminal",
                    ),
                    _ => (Ok(None), "Canceled"),
                };
                tx.send(response).await.unwrap();
                Ok::<_, hyper::Error>(hyper::Response::new(message.to_owned()))
            }
        });
        loop {
            let (connection, _addr) = listener.accept().await.unwrap();
            server
                .serve_connection(hyper_util::rt::TokioIo::new(connection), svc)
                .await
                .unwrap();
        }
    });
    (handle, rx)
}

async fn add_ssh_alias(
    login: &mut crate::keys::LoginInfo,
    host_url: &url::Url,
    keys: &mut crate::keys::KeyInfo,
) {
    let api = match login.api_for(host_url).await {
        Ok(x) => x,
        Err(_) => return,
    };
    if let Some(ssh_url) = get_instance_ssh_url(api).await {
        let http_host = crate::host_with_port(&host_url);
        let ssh_host = crate::host_with_port(&ssh_url);
        if http_host != ssh_host {
            keys.aliases
                .insert(ssh_host.to_string(), http_host.to_string());
        }
    }
}

async fn get_instance_ssh_url(api: forgejo_api::Forgejo) -> Option<url::Url> {
    let query = forgejo_api::structs::RepoSearchQuery {
        limit: Some(1),
        ..Default::default()
    };
    let results = api.repo_search(query).await.ok()?;
    if let Some(mut repos) = results.data {
        if let Some(repo) = repos.pop() {
            if let Some(ssh_url) = repo.ssh_url {
                return Some(ssh_url);
            }
        }
    }
    None
}
07070100000010000081A4000000000000000000000001681904CF00001802000000000000000000000000000000000000002400000000forgejo-cli-0.3.0/src/completion.rsuse std::io::Write;

use clap::{ArgAction, Args, CommandFactory, ValueEnum};
use eyre::OptionExt;

#[derive(Args, Clone, Debug)]
pub struct CompletionCommand {
    shell: Shell,
    #[clap(long)]
    bin_name: Option<String>,
}

#[derive(ValueEnum, Clone, Debug)]
pub enum Shell {
    Bash,
    Elvish,
    Fish,
    PowerShell,
    Zsh,
    Nushell,
}

impl CompletionCommand {
    pub fn run(self) {
        use clap_complete::Shell as CCShell;
        use Shell::*;
        let mut cmd = crate::App::command();
        let app_name = self.bin_name.as_deref().unwrap_or("fj");
        let mut writer = std::io::stdout();
        match self.shell {
            Bash => clap_complete::generate(CCShell::Bash, &mut cmd, app_name, &mut writer),
            Elvish => clap_complete::generate(CCShell::Elvish, &mut cmd, app_name, &mut writer),
            Fish => clap_complete::generate(CCShell::Fish, &mut cmd, app_name, &mut writer),
            PowerShell => {
                clap_complete::generate(CCShell::PowerShell, &mut cmd, app_name, &mut writer)
            }
            Zsh => clap_complete::generate(CCShell::Zsh, &mut cmd, app_name, &mut writer),
            Nushell => clap_complete::generate(NushellCompletion, &mut cmd, app_name, &mut writer),
        }
    }
}

// Heavily inspired by clap_complete_nushell
// but rewritten/modified since I'm not a fan of its completions

struct NushellCompletion;

impl clap_complete::Generator for NushellCompletion {
    fn file_name(&self, name: &str) -> String {
        format!("{name}.nu")
    }

    fn generate(&self, cmd: &clap::Command, buf: &mut dyn Write) {
        generate_command(cmd, buf).expect("failed to generate nushell completions");
    }
}

fn generate_command(cmd: &clap::Command, buf: &mut dyn Write) -> eyre::Result<()> {
    writeln!(buf, "module completions {{")?;
    generate_subcommand(cmd, buf)?;
    writeln!(buf, "}}")?;
    writeln!(buf, "export use completions *")?;
    Ok(())
}

fn generate_subcommand(cmd: &clap::Command, buf: &mut dyn Write) -> eyre::Result<()> {
    let name = cmd.get_bin_name().ok_or_eyre("no bin name")?;
    writeln!(buf, "  export extern \"{name}\" [")?;
    let mut args = cmd.get_arguments().collect::<Vec<_>>();
    args.sort_by_key(|arg| arg.is_positional());

    // positional arguments
    for arg in cmd.get_arguments() {
        if !arg.is_positional() {
            continue;
        }

        write!(buf, "    ")?;
        let id = arg.get_id().as_str();

        if matches!(arg.get_action(), ArgAction::Append) {
            write!(buf, "...{id}")?;
        } else {
            write!(buf, "{id}")?;
            if !arg.is_required_set() {
                write!(buf, "?")?;
            }
        }

        arg_type(name, arg, buf)?;
        writeln!(buf)?;
    }

    // subcommand completion
    if cmd.get_subcommands().next().is_some() {
        // basically `!is_empty`
        writeln!(buf, "    rest?: string@\"complete-subcommand {name}\",")?;
    }

    // flag arguments
    for arg in cmd.get_arguments() {
        match (arg.get_long(), arg.get_short()) {
            (Some(long), Some(short)) => write!(buf, "    --{long}(-{short})")?,
            (Some(long), None) => write!(buf, "    --{long}")?,
            (None, Some(short)) => write!(buf, "    -{short}")?,
            (None, None) => continue,
        }
        arg_type(name, arg, buf)?;
        writeln!(buf)?;
    }
    writeln!(buf, "  ]")?;
    writeln!(buf)?;

    // argument completions
    for arg in cmd.get_arguments() {
        let possible_values = arg.get_possible_values();
        if possible_values.is_empty() {
            continue;
        }
        writeln!(
            buf,
            "  def \"complete-value {name} {}\" [] {{",
            arg.get_id().as_str()
        )?;
        writeln!(buf, "    [")?;
        for possible_value in &possible_values {
            write!(buf, "      {{ value: \"{}\"", possible_value.get_name())?;
            if let Some(help) = possible_value.get_help() {
                write!(buf, ", description: \"{help}\"")?;
            }
            writeln!(buf, " }},")?;
        }
        writeln!(buf, "    ]")?;
        writeln!(buf, "  }}")?;
        writeln!(buf)?;
    }

    // subcommand completion
    if cmd.get_subcommands().count() != 0 {
        writeln!(buf, "  def \"complete-subcommand {name}\" [] {{")?;
        writeln!(buf, "    [")?;
        for subcommand in cmd.get_subcommands() {
            write!(buf, "      {{ value: \"{}\"", subcommand.get_name())?;
            if let Some(about) = subcommand.get_about() {
                write!(buf, ", description: \"{about}\"")?;
            }
            writeln!(buf, " }},")?;
        }
        writeln!(buf, "    ]")?;
        writeln!(buf, "  }}")?;
        writeln!(buf)?;
    }

    for subcommand in cmd.get_subcommands() {
        generate_subcommand(subcommand, buf)?;
    }
    Ok(())
}

fn arg_type(cmd_name: &str, arg: &clap::Arg, buf: &mut dyn Write) -> eyre::Result<()> {
    use clap::ValueHint;
    let takes_values = arg
        .get_num_args()
        .map(|r| r.takes_values())
        .unwrap_or_default();
    if takes_values {
        let type_name = match arg.get_value_hint() {
            ValueHint::Unknown => "string",
            ValueHint::Other => "string",
            ValueHint::AnyPath => "path",
            ValueHint::FilePath => "path",
            ValueHint::DirPath => "path",
            ValueHint::ExecutablePath => "path",
            ValueHint::CommandName => "string",
            ValueHint::CommandString => "path",
            ValueHint::CommandWithArguments => "string",
            ValueHint::Username => "string",
            ValueHint::Hostname => "string",
            ValueHint::Url => "string",
            ValueHint::EmailAddress => "string",
            _ => "string",
        };

        write!(buf, ": {type_name}")?;
    }

    let possible_values = arg.get_possible_values();
    if !possible_values.is_empty() {
        write!(
            buf,
            "@\"complete-value {cmd_name} {}\"",
            arg.get_id().as_str()
        )?;
    }

    Ok(())
}
07070100000011000081A4000000000000000000000001681904CF00004EEC000000000000000000000000000000000000002000000000forgejo-cli-0.3.0/src/issues.rsuse std::str::FromStr;

use clap::{Args, Subcommand};
use eyre::{eyre, Context, OptionExt};
use forgejo_api::structs::{
    Comment, CreateIssueCommentOption, CreateIssueOption, EditIssueOption, IssueGetCommentsQuery,
};
use forgejo_api::Forgejo;

use crate::repo::{RepoArg, RepoInfo, RepoName};

#[derive(Args, Clone, Debug)]
pub struct IssueCommand {
    /// The local git remote that points to the repo to operate on.
    #[clap(long, short = 'R')]
    remote: Option<String>,
    #[clap(subcommand)]
    command: IssueSubcommand,
}

#[derive(Subcommand, Clone, Debug)]
pub enum IssueSubcommand {
    /// Create a new issue on a repo
    Create {
        /// Title of the issue
        title: Option<String>,
        /// The text body of the issue
        ///
        /// Leaving this out will open your editor.
        #[clap(long)]
        body: Option<String>,
        /// The repo to create this issue on
        #[clap(long, short)]
        repo: Option<RepoArg>,
        /// Open the PR creation page in your web browser
        #[clap(long)]
        web: bool,
    },
    /// Edit an issue
    Edit {
        issue: IssueId,
        #[clap(subcommand)]
        command: EditCommand,
    },
    /// Add a comment on an issue
    Comment {
        issue: IssueId,
        body: Option<String>,
    },
    /// Close an issue
    Close {
        issue: IssueId,
        /// A comment to leave on the issue before closing it
        #[clap(long, short)]
        with_msg: Option<Option<String>>,
    },
    /// Search for an issue in a repo
    Search {
        #[clap(long, short)]
        repo: Option<RepoArg>,
        query: Option<String>,
        #[clap(long, short)]
        labels: Option<String>,
        #[clap(long, short)]
        creator: Option<String>,
        #[clap(long, short)]
        assignee: Option<String>,
        #[clap(long, short)]
        state: Option<State>,
    },
    /// View an issue's info
    View {
        id: IssueId,
        #[clap(subcommand)]
        command: Option<ViewCommand>,
    },
    /// Open an issue in your browser
    Browse { id: IssueId },
}

#[derive(Clone, Debug)]
pub struct IssueId {
    pub repo: Option<RepoArg>,
    pub number: u64,
}

impl FromStr for IssueId {
    type Err = IssueIdError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let (repo, number) = match s.rsplit_once("#") {
            Some((repo, number)) => (Some(repo.parse::<RepoArg>()?), number),
            None => (None, s),
        };
        Ok(Self {
            repo,
            number: number.parse()?,
        })
    }
}

#[derive(Debug, Clone)]
pub enum IssueIdError {
    Repo(crate::repo::RepoArgError),
    Number(std::num::ParseIntError),
}

impl std::fmt::Display for IssueIdError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            IssueIdError::Repo(e) => e.fmt(f),
            IssueIdError::Number(e) => e.fmt(f),
        }
    }
}

impl From<crate::repo::RepoArgError> for IssueIdError {
    fn from(value: crate::repo::RepoArgError) -> Self {
        Self::Repo(value)
    }
}

impl From<std::num::ParseIntError> for IssueIdError {
    fn from(value: std::num::ParseIntError) -> Self {
        Self::Number(value)
    }
}

impl std::error::Error for IssueIdError {}

#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum State {
    Open,
    Closed,
}

impl From<State> for forgejo_api::structs::IssueListIssuesQueryState {
    fn from(value: State) -> Self {
        match value {
            State::Open => forgejo_api::structs::IssueListIssuesQueryState::Open,
            State::Closed => forgejo_api::structs::IssueListIssuesQueryState::Closed,
        }
    }
}

#[derive(Subcommand, Clone, Debug)]
pub enum EditCommand {
    /// Edit an issue's title
    Title { new_title: Option<String> },
    /// Edit an issue's text content
    Body { new_body: Option<String> },
    /// Edit a comment on an issue
    Comment {
        idx: usize,
        new_body: Option<String>,
    },
}

#[derive(Subcommand, Clone, Debug)]
pub enum ViewCommand {
    /// View an issue's title and body. The default
    Body,
    /// View a specific
    Comment { idx: usize },
    /// List every comment
    Comments,
}

impl IssueCommand {
    pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
        use IssueSubcommand::*;
        let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref(), &keys)?;
        let api = keys.get_api(repo.host_url()).await?;
        let repo = repo.name().ok_or_else(|| self.no_repo_error())?;
        match self.command {
            Create {
                repo: _,
                title,
                body,
                web,
            } => create_issue(repo, &api, title, body, web).await?,
            View { id, command } => match command.unwrap_or(ViewCommand::Body) {
                ViewCommand::Body => view_issue(repo, &api, id.number).await?,
                ViewCommand::Comment { idx } => view_comment(repo, &api, id.number, idx).await?,
                ViewCommand::Comments => view_comments(repo, &api, id.number).await?,
            },
            Search {
                repo: _,
                query,
                labels,
                creator,
                assignee,
                state,
            } => view_issues(repo, &api, query, labels, creator, assignee, state).await?,
            Edit { issue, command } => match command {
                EditCommand::Title { new_title } => {
                    edit_title(repo, &api, issue.number, new_title).await?
                }
                EditCommand::Body { new_body } => {
                    edit_body(repo, &api, issue.number, new_body).await?
                }
                EditCommand::Comment { idx, new_body } => {
                    edit_comment(repo, &api, issue.number, idx, new_body).await?
                }
            },
            Close { issue, with_msg } => close_issue(repo, &api, issue.number, with_msg).await?,
            Browse { id } => browse_issue(repo, &api, id.number).await?,
            Comment { issue, body } => add_comment(repo, &api, issue.number, body).await?,
        }
        Ok(())
    }

    fn repo(&self) -> Option<&RepoArg> {
        use IssueSubcommand::*;
        match &self.command {
            Create { repo, .. } | Search { repo, .. } => repo.as_ref(),
            View { id: issue, .. }
            | Edit { issue, .. }
            | Close { issue, .. }
            | Comment { issue, .. }
            | Browse { id: issue, .. } => issue.repo.as_ref(),
        }
    }

    fn no_repo_error(&self) -> eyre::Error {
        use IssueSubcommand::*;
        match &self.command {
            Create { .. } | Search { .. } => {
                eyre::eyre!("can't figure what repo to access, try specifying with `--repo`")
            }
            View { id: issue, .. }
            | Edit { issue, .. }
            | Close { issue, .. }
            | Comment { issue, .. }
            | Browse { id: issue, .. } => eyre::eyre!(
                "can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{}`",
                issue.number
            ),
        }
    }
}

async fn create_issue(
    repo: &RepoName,
    api: &Forgejo,
    title: Option<String>,
    body: Option<String>,
    web: bool,
) -> eyre::Result<()> {
    match (title, web) {
        (Some(title), false) => {
            let body = match body {
                Some(body) => body,
                None => {
                    let mut body = String::new();
                    crate::editor(&mut body, Some("md")).await?;
                    body
                }
            };
            let issue = api
                .issue_create_issue(
                    repo.owner(),
                    repo.name(),
                    CreateIssueOption {
                        body: Some(body),
                        title,
                        assignee: None,
                        assignees: None,
                        closed: None,
                        due_date: None,
                        labels: None,
                        milestone: None,
                        r#ref: None,
                    },
                )
                .await?;
            let number = issue
                .number
                .ok_or_else(|| eyre::eyre!("issue does not have number"))?;
            let title = issue
                .title
                .as_ref()
                .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
            eprintln!("created issue #{}: {}", number, title);
        }
        (None, true) => {
            let base_repo = api.repo_get(repo.owner(), repo.name()).await?;
            let mut issue_create_url = base_repo
                .html_url
                .clone()
                .ok_or_eyre("repo does not have html url")?;
            issue_create_url
                .path_segments_mut()
                .expect("invalid url")
                .extend(["issues", "new"]);
            open::that_detached(issue_create_url.as_str()).wrap_err("Failed to open URL")?;
        }
        (None, false) => {
            eyre::bail!("requires either issue title or --web flag")
        }
        (Some(_), true) => {
            eyre::bail!("issue title and --web flag are mutually exclusive")
        }
    }
    Ok(())
}

pub async fn view_issue(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
    let crate::SpecialRender {
        dash,

        bright_red,
        bright_green,
        yellow,
        dark_grey,
        white,
        reset,
        ..
    } = crate::special_render();

    let issue = api.issue_get_issue(repo.owner(), repo.name(), id).await?;

    // if it's a pull request, display it as one instead
    if issue.pull_request.is_some() {
        crate::prs::view_pr(repo, api, Some(id)).await?;
        return Ok(());
    }

    let title = issue
        .title
        .as_ref()
        .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
    let user = issue
        .user
        .as_ref()
        .ok_or_else(|| eyre::eyre!("issue does not have creator"))?;
    let username = user
        .login
        .as_ref()
        .ok_or_else(|| eyre::eyre!("user does not have login"))?;
    let state = issue
        .state
        .ok_or_else(|| eyre::eyre!("pr does not have state"))?;
    let comments = issue.comments.unwrap_or_default();

    println!("{yellow}{title} {dark_grey}#{id}{reset}");
    print!("By {white}{username}{reset} {dash} ");

    use forgejo_api::structs::StateType;
    match state {
        StateType::Open => println!("{bright_green}Open{reset}"),
        StateType::Closed => println!("{bright_red}Closed{reset}"),
    };

    if let Some(body) = &issue.body {
        if !body.is_empty() {
            println!();
            println!("{}", crate::markdown(body));
        }
    }
    println!();

    if comments == 1 {
        println!("1 comment");
    } else {
        println!("{comments} comments");
    }
    Ok(())
}
async fn view_issues(
    repo: &RepoName,
    api: &Forgejo,
    query_str: Option<String>,
    labels: Option<String>,
    creator: Option<String>,
    assignee: Option<String>,
    state: Option<State>,
) -> eyre::Result<()> {
    let labels = labels
        .map(|s| s.split(',').map(|s| s.to_string()).collect::<Vec<_>>())
        .unwrap_or_default();
    let mut query = forgejo_api::structs::IssueListIssuesQuery {
        q: query_str,
        labels: Some(labels.join(",")),
        created_by: creator,
        assigned_by: assignee,
        state: state.map(|s| s.into()),
        r#type: None,
        milestones: None,
        since: None,
        before: None,
        mentioned_by: None,
        page: None,
        limit: None,
    };
    let mut issues = Vec::new();
    for page_idx in 1.. {
        query.page = Some(page_idx);
        let (headers, page) = api
            .issue_list_issues(repo.owner(), repo.name(), query.clone())
            .await?;
        issues.extend(page);
        if issues.len() >= headers.x_total_count.unwrap_or_default() as usize {
            break;
        }
    }
    if issues.len() == 1 {
        println!("1 issue");
    } else {
        println!("{} issues", issues.len());
    }
    for issue in issues {
        let number = issue
            .number
            .ok_or_else(|| eyre::eyre!("issue does not have number"))?;
        let title = issue
            .title
            .as_ref()
            .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
        let user = issue
            .user
            .as_ref()
            .ok_or_else(|| eyre::eyre!("issue does not have creator"))?;
        let username = user
            .login
            .as_ref()
            .ok_or_else(|| eyre::eyre!("user does not have login"))?;
        println!("#{}: {} (by {})", number, title, username);
    }
    Ok(())
}

pub async fn view_comment(repo: &RepoName, api: &Forgejo, id: u64, idx: usize) -> eyre::Result<()> {
    let query = IssueGetCommentsQuery {
        since: None,
        before: None,
    };
    let (_, comments) = api
        .issue_get_comments(repo.owner(), repo.name(), id, query)
        .await?;
    let comment = comments
        .get(idx)
        .ok_or_else(|| eyre!("comment {idx} doesn't exist"))?;
    print_comment(comment)?;
    Ok(())
}

pub async fn view_comments(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
    let query = IssueGetCommentsQuery {
        since: None,
        before: None,
    };
    let (_, comments) = api
        .issue_get_comments(repo.owner(), repo.name(), id, query)
        .await?;
    for comment in comments {
        print_comment(&comment)?;
    }
    Ok(())
}

fn print_comment(comment: &Comment) -> eyre::Result<()> {
    let body = comment
        .body
        .as_ref()
        .ok_or_else(|| eyre::eyre!("comment does not have body"))?;
    let user = comment
        .user
        .as_ref()
        .ok_or_else(|| eyre::eyre!("comment does not have user"))?;
    let username = user
        .login
        .as_ref()
        .ok_or_else(|| eyre::eyre!("user does not have login"))?;
    println!("{} said:", username);
    println!("{}", crate::markdown(body));
    let assets = comment
        .assets
        .as_ref()
        .ok_or_else(|| eyre::eyre!("comment does not have assets"))?;
    if !assets.is_empty() {
        println!("({} attachments)", assets.len());
    }
    Ok(())
}

pub async fn browse_issue(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
    let issue = api.issue_get_issue(repo.owner(), repo.name(), id).await?;
    let html_url = issue
        .html_url
        .as_ref()
        .ok_or_else(|| eyre::eyre!("issue does not have html_url"))?;
    open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
    Ok(())
}

pub async fn add_comment(
    repo: &RepoName,
    api: &Forgejo,
    issue: u64,
    body: Option<String>,
) -> eyre::Result<()> {
    let body = match body {
        Some(body) => body,
        None => {
            let mut body = String::new();
            crate::editor(&mut body, Some("md")).await?;
            body
        }
    };
    api.issue_create_comment(
        repo.owner(),
        repo.name(),
        issue,
        forgejo_api::structs::CreateIssueCommentOption {
            body,
            updated_at: None,
        },
    )
    .await?;
    Ok(())
}

pub async fn edit_title(
    repo: &RepoName,
    api: &Forgejo,
    issue: u64,
    new_title: Option<String>,
) -> eyre::Result<()> {
    let new_title = match new_title {
        Some(s) => s,
        None => {
            let issue_info = api
                .issue_get_issue(repo.owner(), repo.name(), issue)
                .await?;
            let mut title = issue_info
                .title
                .ok_or_else(|| eyre::eyre!("issue does not have title"))?;
            crate::editor(&mut title, Some("md")).await?;
            title
        }
    };
    let new_title = new_title.trim();
    if new_title.is_empty() {
        eyre::bail!("title cannot be empty");
    }
    if new_title.contains('\n') {
        eyre::bail!("title cannot contain newlines");
    }
    api.issue_edit_issue(
        repo.owner(),
        repo.name(),
        issue,
        forgejo_api::structs::EditIssueOption {
            title: Some(new_title.to_owned()),
            assignee: None,
            assignees: None,
            body: None,
            due_date: None,
            milestone: None,
            r#ref: None,
            state: None,
            unset_due_date: None,
            updated_at: None,
        },
    )
    .await?;
    Ok(())
}

pub async fn edit_body(
    repo: &RepoName,
    api: &Forgejo,
    issue: u64,
    new_body: Option<String>,
) -> eyre::Result<()> {
    let new_body = match new_body {
        Some(s) => s,
        None => {
            let issue_info = api
                .issue_get_issue(repo.owner(), repo.name(), issue)
                .await?;
            let mut body = issue_info
                .body
                .ok_or_else(|| eyre::eyre!("issue does not have body"))?;
            crate::editor(&mut body, Some("md")).await?;
            body
        }
    };
    api.issue_edit_issue(
        repo.owner(),
        repo.name(),
        issue,
        forgejo_api::structs::EditIssueOption {
            body: Some(new_body),
            assignee: None,
            assignees: None,
            due_date: None,
            milestone: None,
            r#ref: None,
            state: None,
            title: None,
            unset_due_date: None,
            updated_at: None,
        },
    )
    .await?;
    Ok(())
}

pub async fn edit_comment(
    repo: &RepoName,
    api: &Forgejo,
    issue: u64,
    idx: usize,
    new_body: Option<String>,
) -> eyre::Result<()> {
    let (_, comments) = api
        .issue_get_comments(
            repo.owner(),
            repo.name(),
            issue,
            IssueGetCommentsQuery {
                since: None,
                before: None,
            },
        )
        .await?;
    let comment = comments
        .get(idx)
        .ok_or_else(|| eyre!("comment not found"))?;
    let new_body = match new_body {
        Some(s) => s,
        None => {
            let mut body = comment
                .body
                .clone()
                .ok_or_else(|| eyre::eyre!("issue does not have body"))?;
            crate::editor(&mut body, Some("md")).await?;
            body
        }
    };
    let id = comment
        .id
        .ok_or_else(|| eyre::eyre!("comment does not have id"))? as u64;
    api.issue_edit_comment(
        repo.owner(),
        repo.name(),
        id,
        forgejo_api::structs::EditIssueCommentOption {
            body: new_body,
            updated_at: None,
        },
    )
    .await?;
    Ok(())
}

pub async fn close_issue(
    repo: &RepoName,
    api: &Forgejo,
    issue: u64,
    message: Option<Option<String>>,
) -> eyre::Result<()> {
    if let Some(message) = message {
        let body = match message {
            Some(m) => m,
            None => {
                let mut s = String::new();
                crate::editor(&mut s, Some("md")).await?;
                s
            }
        };

        let opt = CreateIssueCommentOption {
            body,
            updated_at: None,
        };
        api.issue_create_comment(repo.owner(), repo.name(), issue, opt)
            .await?;
    }

    let edit = EditIssueOption {
        state: Some("closed".into()),
        assignee: None,
        assignees: None,
        body: None,
        due_date: None,
        milestone: None,
        r#ref: None,
        title: None,
        unset_due_date: None,
        updated_at: None,
    };
    let issue_data = api
        .issue_edit_issue(repo.owner(), repo.name(), issue, edit)
        .await?;

    let issue_title = issue_data
        .title
        .as_deref()
        .ok_or_eyre("issue does not have title")?;

    println!("Closed issue {issue}: \"{issue_title}\"");

    Ok(())
}
07070100000012000081A4000000000000000000000001681904CF000014C8000000000000000000000000000000000000001E00000000forgejo-cli-0.3.0/src/keys.rsuse eyre::eyre;
use forgejo_api::{Auth, Forgejo};
use std::{
    collections::{BTreeMap, BTreeSet},
    io::ErrorKind,
};
use tokio::io::AsyncWriteExt;
use url::Url;

#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
pub struct KeyInfo {
    pub hosts: BTreeMap<String, LoginInfo>,
    #[serde(default)]
    pub aliases: BTreeMap<String, String>,
    #[serde(default)]
    pub default_ssh: BTreeSet<String>,
}

impl KeyInfo {
    pub async fn load() -> eyre::Result<Self> {
        let path = directories::ProjectDirs::from("", "Cyborus", "forgejo-cli")
            .ok_or_else(|| eyre!("Could not find data directory"))?
            .data_dir()
            .join("keys.json");
        let json = tokio::fs::read(path).await;
        let this = match json {
            Ok(x) => serde_json::from_slice::<Self>(&x)?,
            Err(e) if e.kind() == ErrorKind::NotFound => {
                eprintln!("keys file not found, creating");
                Self::default()
            }
            Err(e) => return Err(e.into()),
        };
        Ok(this)
    }

    pub async fn save(&self) -> eyre::Result<()> {
        let json = serde_json::to_vec_pretty(self)?;
        let dirs = directories::ProjectDirs::from("", "Cyborus", "forgejo-cli")
            .ok_or_else(|| eyre!("Could not find data directory"))?;
        let path = dirs.data_dir();

        tokio::fs::create_dir_all(path).await?;

        let mut file = tokio::fs::File::create(path.join("keys.json")).await?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            // read+write for user, nothing for everyone else
            file.set_permissions(std::fs::Permissions::from_mode(0o600))
                .await?;
        }
        file.write_all(&json).await?;

        Ok(())
    }

    pub fn get_login(&mut self, url: &Url) -> Option<&mut LoginInfo> {
        let host = crate::host_with_port(url);
        let login_info = self.hosts.get_mut(host)?;
        Some(login_info)
    }

    pub async fn get_api(&mut self, url: &Url) -> eyre::Result<Forgejo> {
        match self.get_login(url) {
            Some(login) => login.api_for(url).await,
            None => Forgejo::with_user_agent(Auth::None, url.clone(), crate::USER_AGENT)
                .map_err(Into::into),
        }
    }

    pub fn deref_alias(&self, url: url::Url) -> url::Url {
        match self.aliases.get(crate::host_with_port(&url)) {
            Some(replacement) => {
                let s = format!(
                    "{}{}{}",
                    &url[..url::Position::BeforeHost],
                    replacement,
                    &url[url::Position::AfterPort..]
                );
                url::Url::parse(&s).unwrap()
            }
            None => url,
        }
    }
}

#[derive(serde::Serialize, serde::Deserialize, Clone)]
#[serde(tag = "type")]
pub enum LoginInfo {
    Application {
        name: String,
        token: String,
    },
    OAuth {
        name: String,
        token: String,
        refresh_token: String,
        expires_at: time::OffsetDateTime,
    },
}

impl LoginInfo {
    pub fn username(&self) -> &str {
        match self {
            LoginInfo::Application { name, .. } => name,
            LoginInfo::OAuth { name, .. } => name,
        }
    }

    pub async fn api_for(&mut self, url: &Url) -> eyre::Result<Forgejo> {
        match self {
            LoginInfo::Application { token, .. } => {
                let api =
                    Forgejo::with_user_agent(Auth::Token(token), url.clone(), crate::USER_AGENT)?;
                Ok(api)
            }
            LoginInfo::OAuth {
                token,
                refresh_token,
                expires_at,
                ..
            } => {
                if time::OffsetDateTime::now_utc() >= *expires_at {
                    let api = Forgejo::with_user_agent(Auth::None, url.clone(), crate::USER_AGENT)?;
                    let client_id =
                        crate::auth::get_client_info_for(url)
                            .await?
                            .ok_or_else(|| {
                                eyre::eyre!("Can't refresh token: no client info for {url}.")
                            })?;
                    let response = api
                        .oauth_get_access_token(forgejo_api::structs::OAuthTokenRequest::Refresh {
                            refresh_token,
                            client_id: &client_id,
                            client_secret: "",
                        })
                        .await?;
                    *token = response.access_token;
                    *refresh_token = response.refresh_token;
                    // A minute less, in case any weirdness happens at the exact moment it
                    // expires. Better to refresh slightly too soon than slightly too late.
                    let expires_in = std::time::Duration::from_secs(
                        response.expires_in.saturating_sub(60) as u64,
                    );
                    *expires_at = time::OffsetDateTime::now_utc() + expires_in;
                }
                let api =
                    Forgejo::with_user_agent(Auth::Token(token), url.clone(), crate::USER_AGENT)?;
                Ok(api)
            }
        }
    }
}
07070100000013000081A4000000000000000000000001681904CF00005C0A000000000000000000000000000000000000001E00000000forgejo-cli-0.3.0/src/main.rsuse std::fmt::Display;
use std::io::IsTerminal;

use clap::{Parser, Subcommand};
use eyre::eyre;
use tokio::io::AsyncWriteExt;

mod keys;
use keys::*;

mod actions;
mod auth;
mod completion;
mod issues;
mod org;
mod prs;
mod release;
mod repo;
mod user;
mod version;
mod whoami;
mod wiki;

pub const USER_AGENT: &str = concat!(
    env!("CARGO_PKG_NAME"),
    "/",
    env!("CARGO_PKG_VERSION"),
    " (",
    env!("CARGO_PKG_REPOSITORY"),
    ")"
);

#[derive(Parser, Debug)]
pub struct App {
    #[clap(long, short = 'H')]
    host: Option<String>,
    #[clap(long)]
    style: Option<Style>,
    #[clap(subcommand)]
    command: Command,
}

#[derive(Subcommand, Clone, Debug)]
pub enum Command {
    #[clap(subcommand)]
    Repo(repo::RepoCommand),
    Issue(issues::IssueCommand),
    Pr(prs::PrCommand),
    Wiki(wiki::WikiCommand),
    Actions(actions::ActionsCommand),
    #[command(name = "whoami")]
    WhoAmI(whoami::WhoAmICommand),
    #[clap(subcommand)]
    Auth(auth::AuthCommand),
    Release(release::ReleaseCommand),
    User(user::UserCommand),
    Org(org::OrgCommand),
    Version(version::VersionCommand),
    Completion(completion::CompletionCommand),
}

#[tokio::main]
async fn main() -> eyre::Result<()> {
    let args = App::parse();

    let _ = SPECIAL_RENDER.set(SpecialRender::new(args.style.unwrap_or_default()));

    let mut keys = KeyInfo::load().await?;

    let host_name = args.host.as_deref();
    // let remote = repo::RepoInfo::get_current(host_name, remote_name)?;
    match args.command {
        Command::Repo(subcommand) => subcommand.run(&mut keys, host_name).await?,
        Command::Issue(subcommand) => subcommand.run(&mut keys, host_name).await?,
        Command::Pr(subcommand) => subcommand.run(&mut keys, host_name).await?,
        Command::Wiki(subcommand) => subcommand.run(&mut keys, host_name).await?,
        Command::Actions(subcommand) => subcommand.run(&mut keys, host_name).await?,
        Command::WhoAmI(command) => command.run(&mut keys, host_name).await?,
        Command::Auth(subcommand) => subcommand.run(&mut keys, host_name).await?,
        Command::Release(subcommand) => subcommand.run(&mut keys, host_name).await?,
        Command::User(subcommand) => subcommand.run(&mut keys, host_name).await?,
        Command::Org(subcommand) => subcommand.run(&mut keys, host_name).await?,
        Command::Version(command) => command.run().await?,
        Command::Completion(subcommand) => subcommand.run(),
    }

    keys.save().await?;
    Ok(())
}

async fn readline(msg: &str) -> eyre::Result<String> {
    use std::io::Write;
    print!("{msg}");
    std::io::stdout().flush()?;
    tokio::task::spawn_blocking(|| {
        let mut input = String::new();
        std::io::stdin().read_line(&mut input)?;
        Ok(input)
    })
    .await?
}

async fn editor(contents: &mut String, ext: Option<&str>) -> eyre::Result<()> {
    let editor = std::path::PathBuf::from(
        std::env::var_os("EDITOR").ok_or_else(|| eyre!("unable to locate editor"))?,
    );

    let (mut file, path) = tempfile(ext).await?;
    file.write_all(contents.as_bytes()).await?;
    drop(file);

    // Async block acting as a try/catch block so that the temp file is deleted even
    // on errors
    let res = async {
        eprint!("waiting on editor\r");
        let flags = get_editor_flags(&editor);
        let status = tokio::process::Command::new(editor)
            .args(flags)
            .arg(&path)
            .status()
            .await?;
        if !status.success() {
            eyre::bail!("editor exited unsuccessfully");
        }

        *contents = tokio::fs::read_to_string(&path).await?;
        eprint!("                 \r");

        Ok(())
    }
    .await;

    tokio::fs::remove_file(path).await?;
    res?;
    Ok(())
}

fn get_editor_flags(editor_path: &std::path::Path) -> &'static [&'static str] {
    let editor_name = match editor_path.file_stem().and_then(|s| s.to_str()) {
        Some(name) => name,
        None => return &[],
    };
    if editor_name == "code" {
        return &["--wait"];
    }
    &[]
}

async fn tempfile(ext: Option<&str>) -> tokio::io::Result<(tokio::fs::File, std::path::PathBuf)> {
    let filename = uuid::Uuid::new_v4();
    let mut path = std::env::temp_dir().join(filename.to_string());
    if let Some(ext) = ext {
        path.set_extension(ext);
    }
    let file = tokio::fs::OpenOptions::new()
        .create(true)
        .read(true)
        .write(true)
        .open(&path)
        .await?;
    Ok((file, path))
}

fn ssh_url_parse(s: &str) -> Result<url::Url, url::ParseError> {
    url::Url::parse(s).or_else(|_| {
        let mut new_s = String::new();
        new_s.push_str("ssh://");

        let auth_end = s.find("@").unwrap_or(0);
        new_s.push_str(&s[..auth_end]);
        new_s.push_str(&s[auth_end..].replacen(":", "/", 1));
        url::Url::parse(&new_s)
    })
}

fn host_with_port(url: &url::Url) -> &str {
    &url[url::Position::BeforeHost..url::Position::AfterPort]
}

fn host_with_port_and_path(url: &url::Url) -> &str {
    &url[url::Position::BeforeHost..url::Position::AfterPath]
}

use std::sync::OnceLock;
static SPECIAL_RENDER: OnceLock<SpecialRender> = OnceLock::new();

fn special_render() -> &'static SpecialRender {
    SPECIAL_RENDER
        .get()
        .expect("attempted to get special characters before that was initialized")
}

#[derive(clap::ValueEnum, Clone, Copy, Debug, Default)]
enum Style {
    /// Use special characters, and colors.
    #[default]
    Fancy,
    /// No special characters and no colors. Always used in non-terminal contexts (i.e. pipes)
    Minimal,
}

struct SpecialRender {
    fancy: bool,

    dash: char,
    bullet: char,
    body_prefix: char,
    horiz_rule: char,

    // Uncomment these as needed
    // red: &'static str,
    bright_red: &'static str,
    // green: &'static str,
    bright_green: &'static str,
    // blue: &'static str,
    bright_blue: &'static str,
    // cyan: &'static str,
    bright_cyan: &'static str,
    yellow: &'static str,
    // bright_yellow: &'static str,
    // magenta: &'static str,
    bright_magenta: &'static str,
    black: &'static str,
    dark_grey: &'static str,
    light_grey: &'static str,
    white: &'static str,
    no_fg: &'static str,
    reset: &'static str,

    dark_grey_bg: &'static str,
    // no_bg: &'static str,
    hide_cursor: &'static str,
    show_cursor: &'static str,
    clear_line: &'static str,

    italic: &'static str,
    bold: &'static str,
    strike: &'static str,
    no_italic_bold: &'static str,
    no_strike: &'static str,
}

impl SpecialRender {
    fn new(display: Style) -> Self {
        let is_tty = std::io::stdout().is_terminal();
        match display {
            _ if !is_tty => Self::minimal(),
            Style::Fancy => Self::fancy(),
            Style::Minimal => Self::minimal(),
        }
    }

    fn fancy() -> Self {
        Self {
            fancy: true,

            dash: '—',
            bullet: '•',
            body_prefix: '▌',
            horiz_rule: '─',

            // red: "\x1b[31m",
            bright_red: "\x1b[91m",
            // green: "\x1b[32m",
            bright_green: "\x1b[92m",
            // blue: "\x1b[34m",
            bright_blue: "\x1b[94m",
            // cyan: "\x1b[36m",
            bright_cyan: "\x1b[96m",
            yellow: "\x1b[33m",
            // bright_yellow: "\x1b[93m",
            // magenta: "\x1b[35m",
            bright_magenta: "\x1b[95m",
            black: "\x1b[30m",
            dark_grey: "\x1b[90m",
            light_grey: "\x1b[37m",
            white: "\x1b[97m",
            no_fg: "\x1b[39m",
            reset: "\x1b[0m",

            dark_grey_bg: "\x1b[100m",
            // no_bg: "\x1b[49",
            hide_cursor: "\x1b[?25l",
            show_cursor: "\x1b[?25h",
            clear_line: "\x1b[2K",

            italic: "\x1b[3m",
            bold: "\x1b[1m",
            strike: "\x1b[9m",
            no_italic_bold: "\x1b[23m",
            no_strike: "\x1b[29m",
        }
    }

    fn minimal() -> Self {
        Self {
            fancy: false,

            dash: '-',
            bullet: '-',
            body_prefix: '>',
            horiz_rule: '-',

            // red: "",
            bright_red: "",
            // green: "",
            bright_green: "",
            // blue: "",
            bright_blue: "",
            // cyan: "",
            bright_cyan: "",
            yellow: "",
            // bright_yellow: "",
            // magenta: "",
            bright_magenta: "",
            black: "",
            dark_grey: "",
            light_grey: "",
            white: "",
            no_fg: "",
            reset: "",

            dark_grey_bg: "",
            // no_bg: "",
            hide_cursor: "",
            show_cursor: "",
            clear_line: "",

            italic: "",
            bold: "",
            strike: "~~",
            no_italic_bold: "",
            no_strike: "~~",
        }
    }
}

fn max_line_length() -> usize {
    let (terminal_width, _) = crossterm::terminal::size().unwrap_or((80, 24));
    (terminal_width as usize - 2).min(80)
}

fn render_text(text: &str) -> String {
    let mut ansi_printer = AnsiPrinter::new(max_line_length());

    ansi_printer.pause_style();
    ansi_printer.prefix();
    ansi_printer.resume_style();
    ansi_printer.text(text);
    ansi_printer.out
}

fn markdown(text: &str) -> String {
    let SpecialRender {
        fancy,

        bullet,
        horiz_rule,
        bright_blue,
        dark_grey_bg,
        body_prefix,
        ..
    } = *special_render();

    if !fancy {
        let mut out = String::new();
        for line in text.lines() {
            use std::fmt::Write;
            let _ = writeln!(&mut out, "{body_prefix} {line}");
        }
        return out;
    }

    let arena = comrak::Arena::new();
    let mut options = comrak::Options::default();
    options.extension.strikethrough = true;
    let root = comrak::parse_document(&arena, text, &options);

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    enum Side {
        Start,
        End,
    }

    let mut explore_stack = Vec::new();
    let mut render_queue = Vec::new();

    explore_stack.extend(root.reverse_children().map(|x| (x, Side::Start)));
    while let Some((node, side)) = explore_stack.pop() {
        if side == Side::Start {
            explore_stack.push((node, Side::End));
            explore_stack.extend(node.reverse_children().map(|x| (x, Side::Start)));
        }
        render_queue.push((node, side));
    }

    let mut list_numbers = Vec::new();

    let max_line_len = max_line_length();

    let mut links = Vec::new();

    let mut ansi_printer = AnsiPrinter::new(max_line_len);
    ansi_printer.pause_style();
    ansi_printer.prefix();
    ansi_printer.resume_style();
    let mut iter = render_queue.into_iter().peekable();
    while let Some((item, side)) = iter.next() {
        use comrak::nodes::NodeValue;
        use Side::*;
        match (&item.data.borrow().value, side) {
            (NodeValue::Paragraph, Start) => (),
            (NodeValue::Paragraph, End) => {
                if iter.peek().is_some_and(|(_, side)| *side == Start) {
                    ansi_printer.newline();
                    ansi_printer.newline();
                }
            }
            (NodeValue::Text(s), Start) => ansi_printer.text(s),
            (NodeValue::Link(_), Start) => {
                ansi_printer.start_fg(bright_blue);
            }
            (NodeValue::Link(link), End) => {
                use std::fmt::Write;
                ansi_printer.stop_fg();
                links.push(link.url.clone());
                let _ = write!(&mut ansi_printer, "({})", links.len());
            }
            (NodeValue::Image(_), Start) => {
                ansi_printer.start_fg(bright_blue);
            }
            (NodeValue::Image(link), End) => {
                use std::fmt::Write;
                ansi_printer.stop_fg();
                links.push(link.url.clone());
                let _ = write!(&mut ansi_printer, "({})", links.len());
            }
            (NodeValue::Code(code), Start) => {
                ansi_printer.pause_style();
                ansi_printer.start_bg(dark_grey_bg);
                ansi_printer.text(&code.literal);
                ansi_printer.resume_style();
            }
            (NodeValue::CodeBlock(code), Start) => {
                if ansi_printer.cur_line_len != 0 {
                    ansi_printer.newline();
                }
                ansi_printer.pause_style();
                ansi_printer.start_bg(dark_grey_bg);
                ansi_printer.text(&code.literal);
                ansi_printer.newline();
                ansi_printer.resume_style();
                ansi_printer.newline();
            }
            (NodeValue::BlockQuote, Start) => {
                ansi_printer.blockquote_depth += 1;
                ansi_printer.pause_style();
                ansi_printer.prefix();
                ansi_printer.resume_style();
            }
            (NodeValue::BlockQuote, End) => {
                ansi_printer.blockquote_depth -= 1;
                ansi_printer.newline();
            }
            (NodeValue::HtmlInline(html), Start) => {
                ansi_printer.pause_style();
                ansi_printer.text(html);
                ansi_printer.resume_style();
            }
            (NodeValue::HtmlBlock(html), Start) => {
                if ansi_printer.cur_line_len != 0 {
                    ansi_printer.newline();
                }
                ansi_printer.pause_style();
                ansi_printer.text(&html.literal);
                ansi_printer.newline();
                ansi_printer.resume_style();
            }

            (NodeValue::Heading(heading), Start) => {
                ansi_printer.reset();
                ansi_printer.start_bold();
                ansi_printer
                    .out
                    .extend(std::iter::repeat('#').take(heading.level as usize));
                ansi_printer.out.push(' ');
                ansi_printer.cur_line_len += heading.level as usize + 1;
            }
            (NodeValue::Heading(_), End) => {
                ansi_printer.reset();
                ansi_printer.newline();
                ansi_printer.newline();
            }

            (NodeValue::List(list), Start) => {
                if list.list_type == comrak::nodes::ListType::Ordered {
                    list_numbers.push(0);
                }
            }
            (NodeValue::List(list), End) => {
                if list.list_type == comrak::nodes::ListType::Ordered {
                    list_numbers.pop();
                }
                ansi_printer.newline();
            }
            (NodeValue::Item(list), Start) => {
                if list.list_type == comrak::nodes::ListType::Ordered {
                    use std::fmt::Write;
                    let number: usize = if let Some(number) = list_numbers.last_mut() {
                        *number += 1;
                        *number
                    } else {
                        0
                    };
                    let _ = write!(&mut ansi_printer, "{number}. ");
                } else {
                    ansi_printer.out.push(bullet);
                    ansi_printer.out.push(' ');
                    ansi_printer.cur_line_len += 2;
                }
            }
            (NodeValue::Item(_), End) => {
                ansi_printer.newline();
            }

            (NodeValue::LineBreak, Start) => ansi_printer.newline(),
            (NodeValue::SoftBreak, Start) => ansi_printer.newline(),
            (NodeValue::ThematicBreak, Start) => {
                if ansi_printer.cur_line_len != 0 {
                    ansi_printer.newline();
                }
                ansi_printer
                    .out
                    .extend(std::iter::repeat(horiz_rule).take(max_line_len));
                ansi_printer.newline();
                ansi_printer.newline();
            }

            (NodeValue::Emph, Start) => ansi_printer.start_italic(),
            (NodeValue::Emph, End) => ansi_printer.stop_italic(),
            (NodeValue::Strong, Start) => ansi_printer.start_bold(),
            (NodeValue::Strong, End) => ansi_printer.stop_bold(),
            (NodeValue::Strikethrough, Start) => ansi_printer.start_strike(),
            (NodeValue::Strikethrough, End) => ansi_printer.stop_strike(),

            (NodeValue::Escaped, Start) => (),
            (_, End) => (),
            (_, Start) => ansi_printer.text("?TODO?"),
        }
    }
    if !links.is_empty() {
        ansi_printer.out.push('\n');
        for (i, url) in links.into_iter().enumerate() {
            use std::fmt::Write;
            let _ = writeln!(&mut ansi_printer.out, "({}. {url} )", i + 1);
        }
    }

    ansi_printer.out
}

#[derive(Default)]
struct RenderStyling {
    bold: bool,
    italic: bool,
    strike: bool,

    fg: Option<&'static str>,
    bg: Option<&'static str>,
}

struct AnsiPrinter {
    special_render: &'static SpecialRender,

    out: String,

    cur_line_len: usize,
    max_line_len: usize,

    blockquote_depth: usize,

    style_frames: Vec<RenderStyling>,
}

impl AnsiPrinter {
    fn new(max_line_len: usize) -> Self {
        Self {
            special_render: special_render(),

            out: String::new(),

            cur_line_len: 0,
            max_line_len,

            blockquote_depth: 0,

            style_frames: vec![RenderStyling::default()],
        }
    }

    fn text(&mut self, text: &str) {
        let mut iter = text.lines().peekable();
        while let Some(mut line) = iter.next() {
            loop {
                let this_len = line.chars().count();
                if self.cur_line_len + this_len > self.max_line_len {
                    let mut split_at = self.max_line_len - self.cur_line_len;
                    loop {
                        if line.is_char_boundary(split_at) {
                            break;
                        }
                        split_at -= 1;
                    }
                    let split_at = line
                        .split_at(split_at)
                        .0
                        .char_indices()
                        .rev()
                        .find(|(_, c)| c.is_whitespace())
                        .map(|(i, _)| i)
                        .unwrap_or(split_at);
                    let (head, tail) = line.split_at(split_at);
                    self.out.push_str(head);
                    self.cur_line_len += split_at;
                    self.newline();
                    line = tail.trim_start();
                } else {
                    self.out.push_str(line);
                    self.cur_line_len += this_len;
                    break;
                }
            }
            if iter.peek().is_some() {
                self.newline();
            }
        }
    }

    // Uncomment if needed
    // fn current_fg(&self) -> Option<&'static str> {
    //     self.current_style().fg
    // }

    fn start_fg(&mut self, color: &'static str) {
        self.current_style_mut().fg = Some(color);
        self.out.push_str(color);
    }

    fn stop_fg(&mut self) {
        self.current_style_mut().fg = None;
        self.out.push_str(self.special_render.no_fg);
    }

    fn current_bg(&self) -> Option<&'static str> {
        self.current_style().bg
    }

    fn start_bg(&mut self, color: &'static str) {
        self.current_style_mut().bg = Some(color);
        self.out.push_str(color);
    }

    // Uncomment if needed
    // fn stop_bg(&mut self) {
    //     self.current_style_mut().bg = None;
    //     self.out.push_str(self.special_render.no_bg);
    // }

    fn is_bold(&self) -> bool {
        self.current_style().bold
    }

    fn start_bold(&mut self) {
        self.current_style_mut().bold = true;
        self.out.push_str(self.special_render.bold);
    }

    fn stop_bold(&mut self) {
        self.current_style_mut().bold = false;
        self.out.push_str(self.special_render.reset);
        if self.is_italic() {
            self.out.push_str(self.special_render.italic);
        }
        if self.is_strike() {
            self.out.push_str(self.special_render.strike);
        }
    }

    fn is_italic(&self) -> bool {
        self.current_style().italic
    }

    fn start_italic(&mut self) {
        self.current_style_mut().italic = true;
        self.out.push_str(self.special_render.italic);
    }

    fn stop_italic(&mut self) {
        self.current_style_mut().italic = false;
        self.out.push_str(self.special_render.no_italic_bold);
        if self.is_bold() {
            self.out.push_str(self.special_render.bold);
        }
    }

    fn is_strike(&self) -> bool {
        self.current_style().strike
    }

    fn start_strike(&mut self) {
        self.current_style_mut().strike = true;
        self.out.push_str(self.special_render.strike);
    }

    fn stop_strike(&mut self) {
        self.current_style_mut().strike = false;
        self.out.push_str(self.special_render.no_strike);
    }

    fn reset(&mut self) {
        *self.current_style_mut() = RenderStyling::default();
        self.out.push_str(self.special_render.reset);
    }

    fn pause_style(&mut self) {
        self.out.push_str(self.special_render.reset);
        self.style_frames.push(RenderStyling::default());
    }

    fn resume_style(&mut self) {
        self.out.push_str(self.special_render.reset);
        self.style_frames.pop();
        if let Some(bg) = self.current_bg() {
            self.out.push_str(bg);
        }
        if self.is_bold() {
            self.out.push_str(self.special_render.bold);
        }
        if self.is_italic() {
            self.out.push_str(self.special_render.italic);
        }
        if self.is_strike() {
            self.out.push_str(self.special_render.strike);
        }
    }

    fn newline(&mut self) {
        if self.current_bg().is_some() {
            self.out
                .extend(std::iter::repeat(' ').take(self.max_line_len - self.cur_line_len));
        }
        self.pause_style();
        self.out.push('\n');
        self.prefix();
        for _ in 0..self.blockquote_depth {
            self.prefix();
        }
        self.resume_style();
        self.cur_line_len = self.blockquote_depth * 2;
    }

    fn prefix(&mut self) {
        self.out.push_str(self.special_render.dark_grey);
        self.out.push(self.special_render.body_prefix);
        self.out.push(' ');
    }

    fn current_style(&self) -> &RenderStyling {
        self.style_frames.last().expect("Ran out of style frames")
    }

    fn current_style_mut(&mut self) -> &mut RenderStyling {
        self.style_frames
            .last_mut()
            .expect("Ran out of style frames")
    }
}

impl std::fmt::Write for AnsiPrinter {
    fn write_str(&mut self, s: &str) -> std::fmt::Result {
        self.text(s);
        Ok(())
    }
}

/// When formatted, display either the inner value if `Some`, or the fallback if `None`.
struct DisplayOptional<T: Display, F: Display>(Option<T>, F);

impl<T: Display, F: Display> Display for DisplayOptional<T, F> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if let Some(x) = &self.0 {
            write!(f, "{x}")
        } else {
            write!(f, "{}", self.1)
        }
    }
}
07070100000014000041ED000000000000000000000002681904CF00000000000000000000000000000000000000000000001A00000000forgejo-cli-0.3.0/src/org07070100000015000081A4000000000000000000000001681904CF0000616F000000000000000000000000000000000000001D00000000forgejo-cli-0.3.0/src/org.rsuse clap::{Args, Subcommand};
use eyre::OptionExt;
use forgejo_api::{
    structs::{
        CreateLabelOption, CreateOrgOption, EditLabelOption, EditOrgOption, OrgGetAllQuery,
        OrgListCurrentUserOrgsQuery, OrgListLabelsQuery, OrgListMembersQuery,
        OrgListPublicMembersQuery, OrgListReposQuery,
    },
    Forgejo,
};

use crate::{repo::RepoInfo, SpecialRender};

mod team;

#[derive(Args, Clone, Debug)]
pub struct OrgCommand {
    /// The local git remote that points to the repo to operate on.
    #[clap(long, short = 'R')]
    remote: Option<String>,
    #[clap(subcommand)]
    command: OrgSubcommand,
}

#[derive(Subcommand, Clone, Debug)]
pub enum OrgSubcommand {
    /// List all organizations
    List {
        /// Which page of the results to view
        #[clap(long, short)]
        page: Option<u32>,
        /// Only list organizations you are a member of.
        #[clap(long, short)]
        only_member_of: bool,
    },
    /// View info about an organization
    View {
        /// The name of the organization to view.
        name: String,
    },
    /// Create a new organization
    Create {
        /// The username for the organization.
        ///
        /// It can only have alphanumeric characters, dash, underscore, or period. It must start
        /// and end with an alphanumeric character, and can't have consecutive dashes, underscores,
        /// or periods.
        ///
        /// If you want a name that doesn't have these restrictions, see the `--full-name` option.
        name: String,
        #[clap(flatten)]
        options: OrgOptions,
    },
    /// Edit an organization's information.
    Edit {
        /// The name of the organization to edit.
        ///
        /// Note that this is the username, *not* the display name.
        name: String,
        #[clap(flatten)]
        options: OrgOptions,
    },
    /// View the activity in an organization
    Activity {
        /// The name of the organization to view activity for.
        name: String,
    },
    /// List the members of an organization
    Members {
        /// The name of the organization to view the members of.
        org: String,
        /// Which page of the results to view
        #[clap(long, short)]
        page: Option<u32>,
    },
    /// View and change the visibility of your membership in an organization
    Visibility {
        /// The name of the organization to view your visibility in.
        org: String,
        /// Set a new visibility for yourself.
        #[clap(long, short)]
        set: Option<OrgMemberVisibility>,
    },
    #[clap(subcommand)]
    Team(team::TeamSubcommand),
    #[clap(subcommand)]
    Label(LabelSubcommand),
    #[clap(subcommand)]
    Repo(RepoSubcommand),
}

#[derive(Args, Clone, Debug)]
pub struct OrgOptions {
    /// The display name for the organization.
    ///
    /// This doesn't have the restrictions the `name` argument does, and can contain any UTF-8
    /// text.
    #[clap(long, short)]
    full_name: Option<String>,
    /// The organization's description
    #[clap(long, short)]
    description: Option<String>,
    /// Contact email for the organization
    #[clap(long, short)]
    email: Option<String>,
    /// The organizations's location
    #[clap(long, short)]
    location: Option<String>,
    /// The organization's website
    #[clap(long, short)]
    website: Option<String>,
    /// The visibility of the organization.
    ///
    /// Public organizations can be viewed by anyone, limited orgs can only be viewed by
    /// logged-in users, and private orgs can only be viewed by members of that org.
    #[clap(long, short)]
    visibility: Option<OrgVisibility>,
    /// Whether the admin of a repo can change org teams' access to it.
    #[clap(long, short)]
    admin_can_change_team_access: Option<bool>,
}

#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum OrgMemberVisibility {
    Private,
    Public,
}

#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
pub enum OrgVisibility {
    Private,
    Limited,
    Public,
}

impl Into<forgejo_api::structs::CreateOrgOptionVisibility> for OrgVisibility {
    fn into(self) -> forgejo_api::structs::CreateOrgOptionVisibility {
        use forgejo_api::structs::CreateOrgOptionVisibility as ApiVis;
        match self {
            OrgVisibility::Private => ApiVis::Private,
            OrgVisibility::Limited => ApiVis::Limited,
            OrgVisibility::Public => ApiVis::Public,
        }
    }
}

impl Into<forgejo_api::structs::EditOrgOptionVisibility> for OrgVisibility {
    fn into(self) -> forgejo_api::structs::EditOrgOptionVisibility {
        use forgejo_api::structs::EditOrgOptionVisibility as ApiVis;
        match self {
            OrgVisibility::Private => ApiVis::Private,
            OrgVisibility::Limited => ApiVis::Limited,
            OrgVisibility::Public => ApiVis::Public,
        }
    }
}

impl OrgCommand {
    pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
        let repo = RepoInfo::get_current(host_name, None, self.remote.as_deref(), &keys)?;
        let api = keys.get_api(repo.host_url()).await?;
        match self.command {
            OrgSubcommand::List {
                page,
                only_member_of,
            } => list_orgs(&api, page, only_member_of).await?,
            OrgSubcommand::View { name } => view_org(&api, name).await?,
            OrgSubcommand::Create { name, options } => create_org(&api, name, options).await?,
            OrgSubcommand::Edit { name, options } => edit_org(&api, name, options).await?,
            OrgSubcommand::Activity { name } => list_activity(&api, name).await?,
            OrgSubcommand::Members { org, page } => list_org_members(&api, org, page).await?,
            OrgSubcommand::Visibility { org, set } => member_visibility(&api, org, set).await?,
            OrgSubcommand::Team(subcommand) => subcommand.run(&api).await?,
            OrgSubcommand::Label(subcommand) => subcommand.run(&api).await?,
            OrgSubcommand::Repo(subcommand) => subcommand.run(keys, &repo, &api).await?,
        }
        Ok(())
    }
}

fn is_valid_name_char(c: char) -> bool {
    match c {
        '-' | '_' | '.' => true,
        _ => c.is_ascii_alphanumeric(),
    }
}

async fn list_orgs(api: &Forgejo, page: Option<u32>, only_member_of: bool) -> eyre::Result<()> {
    let (count, orgs) = if only_member_of {
        let query = OrgListCurrentUserOrgsQuery {
            page,
            limit: Some(20),
        };
        let (headers, orgs) = api.org_list_current_user_orgs(query).await?;
        (headers.x_total_count.unwrap_or_default() as u64, orgs)
    } else {
        let query = OrgGetAllQuery {
            page,
            limit: Some(20),
        };
        let (headers, orgs) = api.org_get_all(query).await?;
        (headers.x_total_count.unwrap_or_default() as u64, orgs)
    };

    if orgs.is_empty() {
        println!("No results");
    } else {
        let SpecialRender {
            bullet,
            bold,
            reset,
            ..
        } = *crate::special_render();
        for org in orgs {
            let name = org.name.ok_or_eyre("org does not have name")?;
            println!("{bullet} {bold}{name}{reset}");
        }
        println!("Page {} of {}", page.unwrap_or(1), count.div_ceil(20));
    }
    Ok(())
}

async fn view_org(api: &Forgejo, name: String) -> eyre::Result<()> {
    let org = api.org_get(&name).await?;

    let SpecialRender {
        bold,
        dash,
        bright_cyan,
        light_grey,
        reset,
        ..
    } = *crate::special_render();

    let name = org.name.as_deref().ok_or_eyre("org does not have name")?;
    let visibility = org
        .visibility
        .as_deref()
        .ok_or_eyre("new org does not have visibility")?;
    let vis_pretty = match visibility {
        "public" => "Public",
        "limited" => "Limited",
        "private" => "Private",
        _ => visibility,
    };

    if let Some(full_name) = &org.full_name {
        print!("{bold}{bright_cyan}{full_name}{reset} {light_grey}({name}){reset}");
    } else {
        print!("{bold}{bright_cyan}{name}{reset}");
    }
    print!(" {dash} {vis_pretty}");
    println!();

    let members_query = forgejo_api::structs::OrgListMembersQuery {
        page: Some(1),
        limit: Some(1),
    };
    let member_count = match api.org_list_members(&name, members_query).await {
        Ok((members_headers, _)) => members_headers.x_total_count.unwrap_or_default(),
        Err(_) => {
            let members_query = forgejo_api::structs::OrgListPublicMembersQuery {
                page: Some(1),
                limit: Some(1),
            };
            let (members_headers, _) = api.org_list_public_members(&name, members_query).await?;
            members_headers.x_total_count.unwrap_or_default()
        }
    };
    print!("{bold}{member_count}{reset} members");
    let teams_query = forgejo_api::structs::OrgListTeamsQuery {
        page: Some(1),
        limit: Some(1),
    };
    if let Ok((teams_headers, _)) = api.org_list_teams(&name, teams_query).await {
        let teams = teams_headers.x_total_count.unwrap_or_default();
        println!(" {dash} {bold}{teams}{reset} teams");
    }
    println!();

    let mut first = true;
    if let Some(website) = &org.website {
        if !website.is_empty() {
            print!("{bold}{website}{reset}");
            first = false;
        }
    }
    if let Some(email) = &org.email {
        if !email.is_empty() {
            if !first {
                print!(" {dash} ");
            }
            print!("{email}");
            first = false;
        }
    }
    if let Some(location) = &org.location {
        if !location.is_empty() {
            if !first {
                print!(" {dash} ");
            }
            print!("{location}");
            first = false;
        }
    }
    if !first {
        println!();
    }

    if let Some(description) = &org.description {
        if !description.is_empty() {
            println!("\n{}\n", crate::markdown(&description));
        }
    }

    Ok(())
}

async fn create_org(api: &Forgejo, name: String, options: OrgOptions) -> eyre::Result<()> {
    if !name.chars().all(is_valid_name_char) {
        eyre::bail!("Organization names can only have alphanumeric characters, dash, underscore, or period. \n  If you want a name with other characters, try setting the --full-name flag");
    }
    if !name
        .chars()
        .next()
        .is_some_and(|c| c.is_ascii_alphanumeric())
    {
        eyre::bail!("Organization names can only start with alphanumeric characters. \n  If you want a name that starts with other characters, try setting the --full-name flag");
    }
    if !name
        .chars()
        .last()
        .is_some_and(|c| c.is_ascii_alphanumeric())
    {
        eyre::bail!("Organization names can only end with alphanumeric characters. \n  If you want a name that ends with other characters, try setting the --full-name flag");
    }
    let mut chars = name.chars().peekable();
    while let Some(c) = chars.next() {
        // because of the prior check, if it isn't alphanumeric, it's definitely one of - _ or .
        if !c.is_alphanumeric() && !chars.peek().is_some_and(|c| c.is_alphanumeric()) {
            eyre::bail!("Organization names can't have consecutive non-alphanumberic characters.\n  If you want that in the name, try setting the --full-name flag");
        }
    }
    let opt = CreateOrgOption {
        description: options.description,
        email: options.email,
        full_name: options.full_name,
        location: options.location,
        repo_admin_change_team_access: options.admin_can_change_team_access,
        username: name,
        visibility: options.visibility.map(|v| v.into()),
        website: options.website,
    };
    let new_org = api.org_create(opt).await?;

    let name = new_org.name.ok_or_eyre("new org does not have name")?;
    let visibility = new_org
        .visibility
        .ok_or_eyre("new org does not have visibility")?;

    let SpecialRender {
        fancy,
        bold,
        light_grey,
        reset,
        ..
    } = *crate::special_render();
    print!("created new {visibility} org ");
    if let Some(full_name) = &new_org.full_name {
        if fancy {
            println!("{bold}{full_name}{reset} {light_grey}({name}){reset}");
        } else {
            println!("\"{full_name}\" ({name})");
        }
    } else {
        if fancy {
            println!("{bold}{name}{reset}");
        } else {
            println!("\"{name}\"");
        }
    }
    Ok(())
}

async fn edit_org(api: &Forgejo, name: String, options: OrgOptions) -> eyre::Result<()> {
    let opt = EditOrgOption {
        description: options.description,
        email: options.email,
        full_name: options.full_name,
        location: options.location,
        repo_admin_change_team_access: options.admin_can_change_team_access,
        visibility: options.visibility.map(|v| v.into()),
        website: options.website,
    };
    api.org_edit(&name, opt).await?;
    Ok(())
}

async fn list_activity(api: &Forgejo, name: String) -> eyre::Result<()> {
    let query = forgejo_api::structs::OrgListActivityFeedsQuery::default();
    let (_, feed) = api.org_list_activity_feeds(&name, query).await?;

    for activity in feed {
        crate::user::print_activity(&activity)?;
    }
    Ok(())
}

async fn list_org_members(api: &Forgejo, org: String, page: Option<u32>) -> eyre::Result<()> {
    let my_username = api
        .user_get_current()
        .await?
        .login
        .ok_or_eyre("current user does not have username")?;
    let (count, users) = if api.org_is_member(&org, &my_username).await.is_ok() {
        let query = OrgListMembersQuery {
            page,
            limit: Some(20),
        };
        let (headers, users) = api.org_list_members(&org, query).await?;
        (headers.x_total_count.unwrap_or_default() as u64, users)
    } else {
        let query = OrgListPublicMembersQuery {
            page,
            limit: Some(20),
        };
        let (headers, users) = api.org_list_public_members(&org, query).await?;
        (headers.x_total_count.unwrap_or_default() as u64, users)
    };

    let SpecialRender {
        bullet,
        light_grey,
        bright_cyan,
        reset,
        ..
    } = crate::special_render();
    if users.is_empty() {
        println!("No results");
    } else {
        for user in users {
            let username = user
                .login
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            match user.full_name.as_deref().filter(|s| !s.is_empty()) {
                Some(full_name) => println!(
                    "{bullet} {bright_cyan}{full_name}{reset} {light_grey}({username}){reset}"
                ),
                None => println!("{bullet} {bright_cyan}{username}{reset}"),
            }
        }
        println!("Page {} of {}", page.unwrap_or(1), count.div_ceil(20));
    }
    Ok(())
}

async fn member_visibility(
    api: &Forgejo,
    org: String,
    visibility: Option<OrgMemberVisibility>,
) -> eyre::Result<()> {
    let username = api
        .user_get_current()
        .await?
        .login
        .ok_or_eyre("current user does not have username")?;
    let SpecialRender {
        bright_blue, reset, ..
    } = crate::special_render();
    if api.org_is_member(&org, &username).await.is_ok() {
        match visibility {
            Some(OrgMemberVisibility::Private) => {
                api.org_conceal_member(&org, &username).await?;
                println!("You are now a private member of {bright_blue}{org}{reset}");
            }
            Some(OrgMemberVisibility::Public) => {
                api.org_conceal_member(&org, &username).await?;
                println!("You are now a public member of {bright_blue}{org}{reset}");
            }
            None => {
                if api.org_is_public_member(&org, &username).await.is_ok() {
                    println!("You are a public member of {bright_blue}{org}{reset}");
                } else {
                    println!("You are a private member of {bright_blue}{org}{reset}");
                }
            }
        }
    } else {
        println!("You are not a member of {bright_blue}{org}{reset}");
    }
    Ok(())
}

#[derive(Subcommand, Clone, Debug)]
pub enum LabelSubcommand {
    /// List all the issue labels an organization uses.
    List {
        /// The name of the organization to list the labels of.
        org: String,
    },
    /// Add a new issue label to an organization.
    Add {
        /// The name of the organization the label should be added to.
        org: String,
        /// The name of the label to add.
        name: String,
        /// The hexcode of the label to add.
        color: String,
        /// A description of what the label is for.
        #[clap(long, short)]
        description: Option<String>,
        /// If this label is named `{scope}/{name}`, make it exclusive with other labels with the
        /// same scope.
        #[clap(long, short)]
        exclusive: bool,
    },
    /// Edit an issue label an organization uses.
    Edit {
        /// The name of the organization the label is in.
        org: String,
        /// The name of the label to edit.
        name: String,
        /// Set a new name for the label.
        #[clap(long, short)]
        new_name: Option<String>,
        /// Set a new hexcode for the label.
        #[clap(long, short)]
        color: Option<String>,
        /// Set a description of what the label is for.
        #[clap(long, short)]
        description: Option<String>,
        /// Set whether this label is exclusive with others of the same scope.
        #[clap(long, short)]
        exclusive: bool,
        /// Set whether this label is archived.
        #[clap(long, short)]
        archived: Option<bool>,
    },
    /// Remove an issue label from an organization.
    Rm {
        /// The name of the organization the label is in.
        org: String,
        /// The name of the label to remove from the organization.
        label: String,
    },
}

impl LabelSubcommand {
    async fn run(self, api: &Forgejo) -> eyre::Result<()> {
        match self {
            LabelSubcommand::List { org } => list_org_labels(&api, org).await?,
            LabelSubcommand::Add {
                org,
                name,
                color,
                description,
                exclusive,
            } => add_org_label(&api, org, name, color, description, exclusive).await?,
            LabelSubcommand::Edit {
                org,
                name,
                new_name,
                color,
                description,
                exclusive,
                archived,
            } => {
                edit_org_label(
                    &api,
                    org,
                    name,
                    new_name,
                    color,
                    description,
                    exclusive,
                    archived,
                )
                .await?
            }
            LabelSubcommand::Rm { org, label } => remove_org_label(&api, org, label).await?,
        }
        Ok(())
    }
}

async fn list_org_labels(api: &Forgejo, org: String) -> eyre::Result<()> {
    crate::prs::render_label_list(&get_all_org_labels(api, &org).await?)?;
    Ok(())
}

async fn get_all_org_labels(
    api: &Forgejo,
    org: &str,
) -> eyre::Result<Vec<forgejo_api::structs::Label>> {
    let mut labels = Vec::new();
    for page_idx in 1.. {
        let query = OrgListLabelsQuery {
            page: Some(page_idx),
            limit: None,
        };
        let (headers, label_page) = api.org_list_labels(&org, query).await?;
        labels.extend(label_page);
        if headers
            .x_total_count
            .is_some_and(|n| n as usize <= labels.len())
        {
            break;
        }
    }
    Ok(labels)
}

async fn find_label_by_name(
    api: &Forgejo,
    org: &str,
    label: &str,
) -> eyre::Result<Option<forgejo_api::structs::Label>> {
    let labels = get_all_org_labels(api, org).await?;
    Ok(labels
        .into_iter()
        .find(|l| l.name.as_deref().is_some_and(|s| s == label)))
}

async fn add_org_label(
    api: &Forgejo,
    org: String,
    name: String,
    color: String,
    description: Option<String>,
    exclusive: bool,
) -> eyre::Result<()> {
    let color = color
        .strip_prefix("#")
        .map(|s| s.to_owned())
        .unwrap_or(color);
    let opt = CreateLabelOption {
        color,
        description,
        exclusive: Some(exclusive),
        is_archived: Some(false),
        name,
    };
    let label = api.org_create_label(&org, opt).await?;
    println!("Created new label {}", crate::prs::render_label(&label)?);
    Ok(())
}

async fn edit_org_label(
    api: &Forgejo,
    org: String,
    name: String,
    new_name: Option<String>,
    color: Option<String>,
    description: Option<String>,
    exclusive: bool,
    archived: Option<bool>,
) -> eyre::Result<()> {
    let old_label = find_label_by_name(api, &org, &name)
        .await?
        .ok_or_eyre("label not found")?;
    let id = old_label.id.ok_or_eyre("label does not have id")?;
    let color = color.map(|color| {
        color
            .strip_prefix("#")
            .map(|s| s.to_owned())
            .unwrap_or(color)
    });
    let opt = EditLabelOption {
        color,
        description,
        exclusive: Some(exclusive),
        is_archived: archived,
        name: new_name,
    };
    let label = api.org_edit_label(&org, id as u64, opt).await?;
    println!(
        "Changed label {} to {}",
        crate::prs::render_label(&old_label)?,
        crate::prs::render_label(&label)?
    );
    Ok(())
}

async fn remove_org_label(api: &Forgejo, org: String, name: String) -> eyre::Result<()> {
    let label = find_label_by_name(api, &org, &name)
        .await?
        .ok_or_eyre("label not found")?;
    let id = label.id.ok_or_eyre("label does not have id")?;
    api.org_delete_label(&org, id as u64).await?;
    println!("Removed label {}", crate::prs::render_label(&label)?);
    Ok(())
}

#[derive(Subcommand, Clone, Debug)]
pub enum RepoSubcommand {
    /// List all the repos owned by this organization.
    List {
        /// The name of the organization to list the repos of.
        org: String,
        /// Which page of the results to view
        #[clap(long, short)]
        page: Option<u32>,
    },
    /// Create a new repository in this organization.
    Create {
        /// The name of the organization to create the repo in.
        org: String,
        #[clap(flatten)]
        args: crate::repo::RepoCreateArgs,
    },
}

impl RepoSubcommand {
    async fn run(
        self,
        keys: &crate::KeyInfo,
        repo_info: &RepoInfo,
        api: &Forgejo,
    ) -> eyre::Result<()> {
        match self {
            RepoSubcommand::List { org, page } => list_org_repos(&api, org, page).await?,
            RepoSubcommand::Create {
                org,
                args:
                    crate::repo::RepoCreateArgs {
                        repo,
                        description,
                        private,
                        remote,
                        push,
                        ssh,
                    },
            } => {
                let url_host = crate::host_with_port(&repo_info.host_url());
                let ssh = ssh
                    .unwrap_or(Some(keys.default_ssh.contains(url_host)))
                    .unwrap_or(true);
                crate::repo::create_repo(
                    &api,
                    Some(org),
                    repo,
                    description,
                    private,
                    remote,
                    push,
                    ssh,
                )
                .await?
            }
        }
        Ok(())
    }
}

async fn list_org_repos(api: &Forgejo, org: String, page: Option<u32>) -> eyre::Result<()> {
    let query = OrgListReposQuery {
        page,
        limit: Some(20),
    };
    let (headers, repos) = api.org_list_repos(&org, query).await?;
    let SpecialRender { bullet, .. } = crate::special_render();
    if repos.is_empty() {
        println!("No results");
    } else {
        for repo in repos {
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            println!("{bullet} {full_name}");
        }
        let count = headers.x_total_count.unwrap_or_default() as u64;
        println!("Page {} of {}", page.unwrap_or(1), count.div_ceil(20));
    }
    Ok(())
}
07070100000016000081A4000000000000000000000001681904CF00005224000000000000000000000000000000000000002200000000forgejo-cli-0.3.0/src/org/team.rsuse std::collections::BTreeMap;

use clap::{Args, Subcommand};
use eyre::OptionExt;
use forgejo_api::{
    structs::{
        CreateTeamOption, EditTeamOption, OrgListTeamMembersQuery, OrgListTeamReposQuery,
        OrgListTeamsQuery,
    },
    Forgejo,
};

use crate::SpecialRender;

#[derive(Subcommand, Clone, Debug)]
pub enum TeamSubcommand {
    /// View all the teams in an organization
    List {
        /// The name of the organization to list the teams in.
        org: String,
    },
    /// View info about a single team
    View {
        /// The name of the organization the team is part of.
        org: String,
        /// The name of the new team
        name: String,
        #[clap(long, short = 'p')]
        list_permissions: bool,
    },
    /// Create a new team
    Create {
        /// The name of the organization to create the team in.
        org: String,
        /// The name of the new team
        ///
        /// This must only contain alphanumeric characters.
        name: String,
        #[clap(flatten)]
        flags: TeamCreateFlags,
        #[clap(flatten)]
        options: TeamOptions,
    },
    /// Edit a team's information and permissions
    Edit {
        /// The name of the organization the team is in.
        org: String,
        /// The name of the team to edit
        name: String,
        /// Can members of this team to create repos in the organization?
        #[clap(long, short)]
        new_name: Option<String>,
        #[clap(flatten)]
        flags: TeamEditFlags,
        #[clap(flatten)]
        options: TeamOptions,
    },
    /// Delete a team from an organization.
    ///
    /// Note that this does NOT delete the repos the team has!
    Delete {
        /// The name of the organization the team is in.
        org: String,
        /// The name of the team to delete
        name: String,
    },
    #[clap(subcommand)]
    Repo(TeamRepoSubcommand),
    #[clap(subcommand)]
    Member(TeamMemberSubcommand),
}

#[derive(Args, Clone, Debug)]
pub struct TeamOptions {
    /// A description of what the team does.
    #[clap(long, short)]
    description: Option<String>,
    /// A comma-separated list of read permissions to give this team
    ///
    /// List of permissions:
    ///  - wiki
    ///  - ext_wiki
    ///  - issues
    ///  - ext_issues
    ///  - pulls
    ///  - projects
    ///  - actions
    ///  - code
    ///  - releases
    ///  - packages
    ///
    /// Alternatively, you can use `all` to allow every read permission.
    #[clap(long, short)]
    read_permissions: Option<String>,
    /// A comma-separated list of read+write permissions to give this team
    ///
    /// List of permissions:
    ///  - wiki
    ///  - ext_wiki
    ///  - issues
    ///  - ext_issues
    ///  - pulls
    ///  - projects
    ///  - actions
    ///  - code
    ///  - releases
    ///  - packages
    ///
    /// Alternatively, you can use `all` to allow every read+write permission
    #[clap(long, short)]
    write_permissions: Option<String>,
}

#[derive(Args, Clone, Debug)]
pub struct TeamCreateFlags {
    /// Allow members of this team to create repos in the organization.
    #[clap(long, short)]
    can_create_repos: bool,
    /// Give this team access to every repo.
    #[clap(long, short)]
    include_all_repos: bool,
    /// Give this team administrator abilities in the organization.
    #[clap(long, short = 'A')]
    admin: bool,
}

#[derive(Args, Clone, Debug)]
pub struct TeamEditFlags {
    /// Allow members of this team to create repos in the organization.
    #[clap(long, short)]
    can_create_repos: Option<bool>,
    /// Give this team access to every repo.
    #[clap(long, short)]
    include_all_repos: Option<bool>,
    /// Give this team administrator abilities in the organization.
    #[clap(long, short = 'A')]
    admin: Option<bool>,
}

impl TeamSubcommand {
    pub async fn run(self, api: &forgejo_api::Forgejo) -> eyre::Result<()> {
        match self {
            TeamSubcommand::List { org } => list_teams(&api, org).await?,
            TeamSubcommand::View {
                org,
                name,
                list_permissions,
            } => view_team(&api, org, name, list_permissions).await?,
            TeamSubcommand::Create {
                org,
                name,
                flags,
                options,
            } => create_team(&api, org, name, flags, options).await?,
            TeamSubcommand::Edit {
                org,
                name,
                new_name,
                flags,
                options,
            } => edit_team(&api, org, name, new_name, flags, options).await?,
            TeamSubcommand::Delete { org, name } => delete_team(&api, org, name).await?,
            TeamSubcommand::Repo(subcommand) => subcommand.run(&api).await?,
            TeamSubcommand::Member(subcommand) => subcommand.run(&api).await?,
        }
        Ok(())
    }
}

async fn find_team_by_name(
    api: &Forgejo,
    org: &str,
    name: &str,
) -> eyre::Result<forgejo_api::structs::Team> {
    let mut seen = 0;
    for page in 1.. {
        let query = OrgListTeamsQuery {
            page: Some(page),
            limit: None,
        };
        let (headers, teams) = api.org_list_teams(&org, query).await?;
        seen += teams.len();
        for team in teams {
            if team
                .name
                .as_deref()
                .is_some_and(|team_name| team_name == name)
            {
                return Ok(team);
            }
        }
        if seen >= headers.x_total_count.unwrap_or_default() as usize {
            break;
        }
    }
    eyre::bail!("Unknown team {name}");
}

async fn list_teams(api: &Forgejo, org: String) -> eyre::Result<()> {
    let mut teams = Vec::new();
    for page_idx in 1.. {
        let query = OrgListTeamsQuery {
            page: Some(page_idx),
            limit: None,
        };
        let (headers, page) = api.org_list_teams(&org, query).await?;
        teams.extend(page);
        if teams.len() >= headers.x_total_count.unwrap_or_default() as usize {
            break;
        }
    }
    teams.sort_unstable_by_key(permission_sort_id);

    let SpecialRender {
        bright_blue,
        bold,
        reset,
        bullet,
        ..
    } = crate::special_render();
    for team in teams {
        let team_name = team.name.as_deref().ok_or_eyre("team does not have name")?;
        println!("{bullet} {bold}{bright_blue}{team_name}{reset}");
    }
    Ok(())
}

fn permission_sort_id(team: &forgejo_api::structs::Team) -> u32 {
    use forgejo_api::structs::TeamPermission as Perm;
    match &team.permission {
        Some(Perm::Owner) => 0,
        Some(Perm::Admin) => 1,
        Some(Perm::Write) => 2,
        Some(Perm::Read) => 3,
        Some(Perm::None) | None => 4,
    }
}

const ALL_UNITS: &[&str] = &[
    "repo.wiki",
    "repo.ext_wiki",
    "repo.issues",
    "repo.ext_issues",
    "repo.pulls",
    "repo.projects",
    "repo.actions",
    "repo.code",
    "repo.releases",
    "repo.packages",
];

async fn view_team(
    api: &Forgejo,
    org: String,
    name: String,
    list_permissions: bool,
) -> eyre::Result<()> {
    let team = find_team_by_name(api, &org, &name).await?;

    let SpecialRender {
        bright_blue,
        bright_red,
        bold,
        reset,
        dash,
        ..
    } = crate::special_render();

    print!("{bright_blue}{bold}{name}{reset} {dash} in org {bold}{org}{reset}");
    if team
        .permission
        .is_some_and(|p| p == forgejo_api::structs::TeamPermission::Admin)
    {
        print!(" {dash} {bright_red}Admin{reset}");
    }
    println!();

    if let Some(description) = &team.description {
        if !description.is_empty() {
            println!("\n{}", crate::markdown(description));
        }
    }

    if list_permissions {
        println!();
        let units = team
            .units_map
            .as_ref()
            .ok_or_eyre("team does not have permission units")?;
        let mut ro_perms = Vec::new();
        let mut rw_perms = Vec::new();
        for (unit, permission) in units {
            match &**permission {
                "read" => ro_perms.push(unit),
                "write" | "admin" | "owner" => rw_perms.push(unit),
                _ => (),
            }
        }

        let get_unit_name = |unit| match unit {
            "repo.wiki" => "Wikis",
            "repo.ext_wiki" => "External Wikis",
            "repo.issues" => "Issues",
            "repo.ext_issues" => "External Issues",
            "repo.pulls" => "Pull Requests",
            "repo.projects" => "Projects",
            "repo.actions" => "CI",
            "repo.code" => "Code",
            "repo.releases" => "Releases",
            "repo.packages" => "Packages",
            _ => "Unknown",
        };
        if !ro_perms.is_empty() {
            print!("Read Only: ");
            for (i, unit) in ro_perms.iter().enumerate() {
                let unit_name = get_unit_name(unit);
                if i > 0 {
                    print!(", ");
                }
                print!("{unit_name}");
            }
            println!();
        }
        if !rw_perms.is_empty() {
            print!("Read/Write: ");
            for (i, unit) in rw_perms.iter().enumerate() {
                let unit_name = get_unit_name(unit);
                if i != 0 {
                    print!(", ");
                }
                print!("{unit_name}");
            }
            println!();
        }
    }

    Ok(())
}

fn create_unit_map(ro_perms: Option<&str>, rw_perms: Option<&str>) -> BTreeMap<String, String> {
    let mut units = BTreeMap::new();
    if let Some(ro_perms) = ro_perms {
        if ro_perms == "all" {
            for ro in ALL_UNITS {
                units.insert(ro.to_string(), "read".to_owned());
            }
        } else {
            for ro in ro_perms.split(",") {
                units.insert(format!("repo.{ro}"), "read".to_owned());
            }
        }
    }
    if let Some(rw_perms) = rw_perms {
        if rw_perms.trim() == "all" {
            for rw in ALL_UNITS {
                units.insert(rw.to_string(), "write".to_owned());
            }
        } else {
            for rw in rw_perms.split(",") {
                units.insert(format!("repo.{rw}"), "write".to_owned());
            }
        }
    }
    units
}

async fn create_team(
    api: &Forgejo,
    org: String,
    name: String,
    flags: TeamCreateFlags,
    options: TeamOptions,
) -> eyre::Result<()> {
    let units = create_unit_map(
        options.read_permissions.as_deref(),
        options.write_permissions.as_deref(),
    );
    let opt = CreateTeamOption {
        can_create_org_repo: Some(flags.can_create_repos),
        description: options.description,
        includes_all_repositories: Some(flags.include_all_repos),
        name,
        permission: flags
            .admin
            .then(|| forgejo_api::structs::CreateTeamOptionPermission::Admin),
        units: None,
        units_map: Some(units),
    };
    let new_team = api.org_create_team(&org, opt).await?;
    let org = new_team.organization.ok_or_eyre("team doesn't have org")?;
    let org_name = org
        .name
        .or(org.full_name)
        .ok_or_eyre("org doesn't have name")?;
    let name = new_team.name.ok_or_eyre("team doesn't have name")?;

    let SpecialRender {
        bright_blue,
        bold,
        reset,
        ..
    } = crate::special_render();
    print!("created new ");
    if flags.admin {
        print!("admin ");
    }
    println!("team {bright_blue}{bold}{name}{reset} in {bold}{org_name}{reset}");
    Ok(())
}

async fn edit_team(
    api: &Forgejo,
    org: String,
    name: String,
    new_name: Option<String>,
    flags: TeamEditFlags,
    options: TeamOptions,
) -> eyre::Result<()> {
    let team = find_team_by_name(api, &org, &name).await?;
    let id = team.id.ok_or_eyre("team does not have id")?;

    // EditTeamOption's team field is a String rather than Option<String>
    // That should be fixed, but this gets around it for now.
    let new_name = new_name.unwrap_or(name);
    let units = create_unit_map(
        options.read_permissions.as_deref(),
        options.write_permissions.as_deref(),
    );

    let options = EditTeamOption {
        can_create_org_repo: flags.can_create_repos,
        description: options.description,
        includes_all_repositories: flags.include_all_repos,
        name: new_name,
        permission: flags
            .admin
            .and_then(|b| b.then(|| forgejo_api::structs::EditTeamOptionPermission::Admin)),
        units: None,
        units_map: Some(units),
    };
    api.org_edit_team(id as u32, options).await?;

    Ok(())
}

async fn delete_team(api: &Forgejo, org: String, name: String) -> eyre::Result<()> {
    let SpecialRender { bold, reset, .. } = crate::special_render();
    println!("Are you sure you want to delete {bold}{org}/{name}{reset}?");
    let confirmation = crate::readline("(y/N) ").await?.to_lowercase();
    if matches!(confirmation.trim(), "y" | "yes") {
        let id = find_team_by_name(api, &org, &name)
            .await?
            .id
            .ok_or_eyre("team does not have id")?;
        api.org_delete_team(id as u64).await?;
        println!("Team deleted.");
    } else {
        println!("Team not deleted.");
    }
    Ok(())
}

#[derive(Subcommand, Clone, Debug)]
pub enum TeamRepoSubcommand {
    /// List all the repos this team can access
    List {
        /// The name of the organization the team is in.
        org: String,
        /// The name of the team to view the repos of.
        team: String,
        /// Which page of the results to view
        #[clap(long, short)]
        page: Option<u32>,
    },
    /// Add access to an existing repo to a team
    Add {
        /// The name of the organization the team is in.
        org: String,
        /// The name of the team to add a repo to.
        team: String,
        /// The name of the repo to add to the team.
        repo: String,
    },
    /// Remove access to a repo from a team
    ///
    /// Note that this does NOT delete the repository!
    Rm {
        /// The name of the organization the team is in.
        org: String,
        /// The name of the team to remove the repo from.
        team: String,
        /// The name of the repo to remove from the team.
        repo: String,
    },
}

impl TeamRepoSubcommand {
    async fn run(self, api: &Forgejo) -> eyre::Result<()> {
        match self {
            TeamRepoSubcommand::List { org, team, page } => {
                list_team_repos(&api, org, team, page).await?
            }
            TeamRepoSubcommand::Add { org, team, repo } => {
                add_repo_to_team(&api, org, team, repo).await?
            }
            TeamRepoSubcommand::Rm { org, team, repo } => {
                remove_repo_from_team(&api, org, team, repo).await?
            }
        }
        Ok(())
    }
}

async fn list_team_repos(
    api: &Forgejo,
    org: String,
    team: String,
    page: Option<u32>,
) -> eyre::Result<()> {
    let id = find_team_by_name(api, &org, &team)
        .await?
        .id
        .ok_or_eyre("team does not have id")?;
    let query = OrgListTeamReposQuery {
        page,
        limit: Some(20),
    };
    let (headers, repos) = api.org_list_team_repos(id as u64, query).await?;

    let SpecialRender { bullet, .. } = crate::special_render();
    if repos.is_empty() {
        println!("No results");
    } else {
        for repo in repos {
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            println!("{bullet} {full_name}");
        }
        let count = headers.x_total_count.unwrap_or_default() as u64;
        println!("Page {} of {}", page.unwrap_or(1), count.div_ceil(20));
    }
    Ok(())
}

async fn add_repo_to_team(
    api: &Forgejo,
    org: String,
    team: String,
    repo: String,
) -> eyre::Result<()> {
    let id = find_team_by_name(api, &org, &team)
        .await?
        .id
        .ok_or_eyre("team does not have id")?;
    api.org_add_team_repository(id as u64, &org, &repo).await?;
    let SpecialRender {
        bold,
        reset,
        bright_blue,
        ..
    } = crate::special_render();
    println!("Added {bold}{org}/{repo}{reset} to team {bright_blue}{bold}{team}{reset}");
    Ok(())
}

async fn remove_repo_from_team(
    api: &Forgejo,
    org: String,
    team: String,
    repo: String,
) -> eyre::Result<()> {
    let id = find_team_by_name(api, &org, &team)
        .await?
        .id
        .ok_or_eyre("team does not have id")?;
    api.org_remove_team_repository(id as u64, &org, &repo)
        .await?;
    let SpecialRender {
        bold,
        reset,
        bright_blue,
        ..
    } = crate::special_render();
    println!("Removed {bold}{org}/{repo}{reset} from team {bright_blue}{bold}{team}{reset}");
    Ok(())
}

#[derive(Subcommand, Clone, Debug)]
pub enum TeamMemberSubcommand {
    /// List all the members of a team
    List {
        /// The name of the organization the team is in.
        org: String,
        /// The name of the team to view the members of.
        team: String,
        /// Which page of the results to view
        #[clap(long, short)]
        page: Option<u32>,
    },
    /// Add someone to a team
    Add {
        /// The name of the organization the team is in.
        org: String,
        /// The name of the team to add a user to.
        team: String,
        /// The name of the user to add to the team.
        user: String,
    },
    /// Remove someone from a team
    Rm {
        /// The name of the organization the team is in.
        org: String,
        /// The name of the team to remove the user from.
        team: String,
        /// The name of the user to remove from the team.
        user: String,
    },
}

impl TeamMemberSubcommand {
    async fn run(self, api: &Forgejo) -> eyre::Result<()> {
        match self {
            TeamMemberSubcommand::List { org, team, page } => {
                list_team_members(&api, org, team, page).await?
            }
            TeamMemberSubcommand::Add { org, team, user } => {
                add_user_to_team(&api, org, team, user).await?
            }
            TeamMemberSubcommand::Rm { org, team, user } => {
                remove_user_from_team(&api, org, team, user).await?
            }
        }
        Ok(())
    }
}

async fn list_team_members(
    api: &Forgejo,
    org: String,
    team: String,
    page: Option<u32>,
) -> eyre::Result<()> {
    let id = find_team_by_name(api, &org, &team)
        .await?
        .id
        .ok_or_eyre("team does not have id")?;
    let query = OrgListTeamMembersQuery {
        page,
        limit: Some(20),
    };
    let (headers, users) = api.org_list_team_members(id as u64, query).await?;

    let SpecialRender {
        bullet,
        light_grey,
        bright_cyan,
        reset,
        ..
    } = crate::special_render();
    if users.is_empty() {
        println!("No results");
    } else {
        for user in users {
            let username = user
                .login
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            match user.full_name.as_deref().filter(|s| !s.is_empty()) {
                Some(full_name) => println!(
                    "{bullet} {bright_cyan}{full_name}{reset} {light_grey}({username}){reset}"
                ),
                None => println!("{bullet} {bright_cyan}{username}{reset}"),
            }
        }
        let count = headers.x_total_count.unwrap_or_default() as u64;
        println!("Page {} of {}", page.unwrap_or(1), count.div_ceil(20));
    }
    Ok(())
}

async fn add_user_to_team(
    api: &Forgejo,
    org: String,
    team: String,
    user: String,
) -> eyre::Result<()> {
    let id = find_team_by_name(api, &org, &team)
        .await?
        .id
        .ok_or_eyre("team does not have id")?;
    api.org_add_team_member(id as u64, &user).await?;
    let SpecialRender {
        bold,
        reset,
        bright_blue,
        bright_cyan,
        ..
    } = crate::special_render();
    println!("Added {bright_cyan}{bold}{user}{reset} to team {bright_blue}{bold}{team}{reset}");
    Ok(())
}

async fn remove_user_from_team(
    api: &Forgejo,
    org: String,
    team: String,
    user: String,
) -> eyre::Result<()> {
    let id = find_team_by_name(api, &org, &team)
        .await?
        .id
        .ok_or_eyre("team does not have id")?;
    api.org_remove_team_member(id as u64, &user).await?;
    let SpecialRender {
        bold,
        reset,
        bright_blue,
        bright_cyan,
        ..
    } = crate::special_render();
    println!("Removed {bright_cyan}{bold}{user}{reset} from team {bright_blue}{bold}{team}{reset}");
    Ok(())
}
07070100000017000081A4000000000000000000000001681904CF0000EE9D000000000000000000000000000000000000001D00000000forgejo-cli-0.3.0/src/prs.rsuse std::{io::Write, str::FromStr};

use clap::{Args, Subcommand};
use eyre::{Context, OptionExt};
use forgejo_api::{
    structs::{
        CreatePullRequestOption, MergePullRequestOption, RepoGetPullRequestCommitsQuery,
        RepoGetPullRequestFilesQuery, StateType,
    },
    Forgejo,
};

use crate::{
    issues::IssueId,
    repo::{RepoArg, RepoInfo, RepoName},
    SpecialRender,
};

#[derive(Args, Clone, Debug)]
pub struct PrCommand {
    /// The local git remote that points to the repo to operate on.
    #[clap(long, short = 'R')]
    remote: Option<String>,
    #[clap(subcommand)]
    command: PrSubcommand,
}

#[derive(Subcommand, Clone, Debug)]
pub enum PrSubcommand {
    /// Search a repository's pull requests
    Search {
        query: Option<String>,
        #[clap(long, short)]
        labels: Option<String>,
        #[clap(long, short)]
        creator: Option<String>,
        #[clap(long, short)]
        assignee: Option<String>,
        #[clap(long, short)]
        state: Option<crate::issues::State>,
        /// The repo to search in
        #[clap(long, short)]
        repo: Option<RepoArg>,
    },
    /// Create a new pull request
    Create {
        /// The branch to merge onto.
        #[clap(long)]
        base: Option<String>,
        /// The branch to pull changes from.
        #[clap(long, group = "source")]
        head: Option<String>,
        /// What to name the new pull request.
        ///
        /// Prefix with "WIP: " to mark this PR as a draft.
        #[clap(group = "web-or-cmd")]
        title: Option<String>,
        /// The text body of the pull request.
        ///
        /// Leaving this out will open your editor.
        #[clap(long)]
        body: Option<String>,
        /// The repo to create this pull request on
        #[clap(long, short)]
        repo: Option<RepoArg>,
        /// Open the PR creation page in your web browser
        #[clap(short, long, group = "web-or-cmd", group = "web-or-agit")]
        web: bool,
        /// Open the PR using AGit workflow
        #[clap(short, long, group = "source", group = "web-or-agit")]
        agit: bool,
    },
    /// View the contents of a pull request
    View {
        /// The pull request to view.
        id: Option<IssueId>,
        #[clap(subcommand)]
        command: Option<ViewCommand>,
    },
    /// View the mergability and CI status of a pull request
    Status {
        /// The pull request to view.
        id: Option<IssueId>,
        /// Wait for all checks to finish before exiting
        #[clap(long)]
        wait: bool,
    },
    /// Checkout a pull request in a new branch
    Checkout {
        /// The pull request to check out.
        ///
        /// Prefix with ^ to get a pull request from the parent repo.
        #[clap(id = "ID")]
        pr: PrNumber,
        /// The name to give the newly created branch.
        ///
        /// Defaults to naming after the host url, repo owner, and PR number.
        #[clap(long, id = "NAME")]
        branch_name: Option<String>,
        /// Pull the commits using SSH instead of HTTP(S).
        #[clap(long, short = 'S')]
        ssh: Option<Option<bool>>,
    },
    /// Add a comment on a pull request
    Comment {
        /// The pull request to comment on.
        pr: Option<IssueId>,
        /// The text content of the comment.
        ///
        /// Not including this in the command will open your editor.
        body: Option<String>,
    },
    /// Edit the contents of a pull request
    Edit {
        /// The pull request to edit.
        pr: Option<IssueId>,
        #[clap(subcommand)]
        command: EditCommand,
    },
    /// Close a pull request, without merging.
    Close {
        /// The pull request to close.
        pr: Option<IssueId>,
        /// A comment to add before closing.
        ///
        /// Adding without an argument will open your editor
        #[clap(long, short)]
        with_msg: Option<Option<String>>,
    },
    /// Merge a pull request
    Merge {
        /// The pull request to merge.
        pr: Option<IssueId>,
        /// The merge style to use.
        #[clap(long, short = 'M')]
        method: Option<MergeMethod>,
        /// Option to delete the corresponding branch afterwards.
        #[clap(long, short)]
        delete: bool,
        /// The title of the merge or squash commit to be created
        #[clap(long, short)]
        title: Option<String>,
        /// The body of the merge or squash commit to be created
        #[clap(long, short)]
        message: Option<Option<String>>,
    },
    /// Open a pull request in your browser
    Browse {
        /// The pull request to open in your browser.
        id: Option<IssueId>,
    },
}

#[derive(clap::ValueEnum, Clone, Copy, Debug)]
pub enum MergeMethod {
    Merge,
    Rebase,
    RebaseMerge,
    Squash,
    Manual,
}

#[derive(Clone, Copy, Debug)]
pub enum PrNumber {
    This(u64),
    Parent(u64),
}

impl PrNumber {
    fn number(self) -> u64 {
        match self {
            PrNumber::This(x) => x,
            PrNumber::Parent(x) => x,
        }
    }
}

impl FromStr for PrNumber {
    type Err = std::num::ParseIntError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if let Some(num) = s.strip_prefix("^") {
            Ok(Self::Parent(num.parse()?))
        } else {
            Ok(Self::This(s.parse()?))
        }
    }
}

impl From<MergeMethod> for forgejo_api::structs::MergePullRequestOptionDo {
    fn from(value: MergeMethod) -> Self {
        use forgejo_api::structs::MergePullRequestOptionDo::*;
        match value {
            MergeMethod::Merge => Merge,
            MergeMethod::Rebase => Rebase,
            MergeMethod::RebaseMerge => RebaseMerge,
            MergeMethod::Squash => Squash,
            MergeMethod::Manual => ManuallyMerged,
        }
    }
}

#[derive(Subcommand, Clone, Debug)]
pub enum EditCommand {
    /// Edit the title
    Title {
        /// New PR title.
        ///
        /// Leaving this out will open the current title in your editor.
        new_title: Option<String>,
    },
    /// Edit the text body
    Body {
        /// New PR body.
        ///
        /// Leaving this out will open the current body in your editor.
        new_body: Option<String>,
    },
    /// Edit a comment
    Comment {
        /// The index of the comment to edit, 0-indexed.
        idx: usize,
        /// New comment body.
        ///
        /// Leaving this out will open the current body in your editor.
        new_body: Option<String>,
    },
    Labels {
        /// The labels to add.
        #[clap(long, short)]
        add: Vec<String>,
        /// The labels to remove.
        #[clap(long, short)]
        rm: Vec<String>,
    },
}

#[derive(Subcommand, Clone, Debug)]
pub enum ViewCommand {
    /// View the title and body of a pull request.
    Body,
    /// View a comment on a pull request.
    Comment {
        /// The index of the comment to view, 0-indexed.
        idx: usize,
    },
    /// View all comments on a pull request.
    Comments,
    /// View the labels applied to a pull request.
    Labels,
    /// View the diff between the base and head branches of a pull request.
    Diff {
        /// Get the diff in patch format
        #[clap(long, short)]
        patch: bool,
        /// View the diff in your text editor
        #[clap(long, short)]
        editor: bool,
    },
    /// View the files changed in a pull request.
    Files,
    /// View the commits in a pull request.
    Commits {
        /// View one commit per line
        #[clap(long, short)]
        oneline: bool,
    },
}

impl PrCommand {
    pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
        use PrSubcommand::*;
        let repo_info =
            RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref(), &keys)?;
        let api = keys.get_api(repo_info.host_url()).await?;
        let repo = repo_info.name().ok_or_else(|| self.no_repo_error())?;
        match self.command {
            Create {
                title,
                base,
                head,
                body,
                repo: _,
                web,
                agit,
            } => {
                create_pr(
                    repo,
                    &api,
                    title,
                    base,
                    head,
                    body,
                    web,
                    agit,
                    repo_info.remote_name(),
                )
                .await?
            }
            Merge {
                pr,
                method,
                delete,
                title,
                message,
            } => {
                merge_pr(
                    repo,
                    &api,
                    pr.map(|id| id.number),
                    method,
                    delete,
                    title,
                    message,
                )
                .await?
            }
            View { id, command } => {
                let id = id.map(|id| id.number);
                match command.unwrap_or(ViewCommand::Body) {
                    ViewCommand::Body => view_pr(repo, &api, id).await?,
                    ViewCommand::Comment { idx } => {
                        let (repo, id) = try_get_pr_number(repo, &api, id).await?;
                        crate::issues::view_comment(&repo, &api, id, idx).await?
                    }
                    ViewCommand::Comments => {
                        let (repo, id) = try_get_pr_number(repo, &api, id).await?;
                        crate::issues::view_comments(&repo, &api, id).await?
                    }
                    ViewCommand::Labels => view_pr_labels(repo, &api, id).await?,
                    ViewCommand::Diff { patch, editor } => {
                        view_diff(repo, &api, id, patch, editor).await?
                    }
                    ViewCommand::Files => view_pr_files(repo, &api, id).await?,
                    ViewCommand::Commits { oneline } => {
                        view_pr_commits(repo, &api, id, oneline).await?
                    }
                }
            }
            Status { id, wait } => view_pr_status(repo, &api, id.map(|id| id.number), wait).await?,
            Search {
                query,
                labels,
                creator,
                assignee,
                state,
                repo: _,
            } => view_prs(repo, &api, query, labels, creator, assignee, state).await?,
            Edit { pr, command } => {
                let pr = pr.map(|pr| pr.number);
                match command {
                    EditCommand::Title { new_title } => {
                        let (repo, id) = try_get_pr_number(repo, &api, pr).await?;
                        crate::issues::edit_title(&repo, &api, id, new_title).await?
                    }
                    EditCommand::Body { new_body } => {
                        let (repo, id) = try_get_pr_number(repo, &api, pr).await?;
                        crate::issues::edit_body(&repo, &api, id, new_body).await?
                    }
                    EditCommand::Comment { idx, new_body } => {
                        let (repo, id) = try_get_pr_number(repo, &api, pr).await?;
                        crate::issues::edit_comment(&repo, &api, id, idx, new_body).await?
                    }
                    EditCommand::Labels { add, rm } => {
                        edit_pr_labels(repo, &api, pr, add, rm).await?
                    }
                }
            }
            Close { pr, with_msg } => {
                let (repo, pr) = try_get_pr_number(repo, &api, pr.map(|pr| pr.number)).await?;
                crate::issues::close_issue(&repo, &api, pr, with_msg).await?
            }
            Checkout {
                pr,
                branch_name,
                ssh,
            } => {
                let url_host = crate::host_with_port(&repo_info.host_url());
                let ssh = ssh
                    .unwrap_or(Some(keys.default_ssh.contains(url_host)))
                    .unwrap_or(true);
                checkout_pr(repo, &api, pr, branch_name, ssh).await?
            }
            Browse { id } => {
                let (repo, id) = try_get_pr_number(repo, &api, id.map(|pr| pr.number)).await?;
                browse_pr(&repo, &api, id).await?
            }
            Comment { pr, body } => {
                let (repo, pr) = try_get_pr_number(repo, &api, pr.map(|pr| pr.number)).await?;
                crate::issues::add_comment(&repo, &api, pr, body).await?
            }
        }
        Ok(())
    }

    fn repo(&self) -> Option<&RepoArg> {
        use PrSubcommand::*;
        match &self.command {
            Search { repo, .. } | Create { repo, .. } => repo.as_ref(),
            Checkout { .. } => None,
            View { id: pr, .. }
            | Status { id: pr, .. }
            | Comment { pr, .. }
            | Edit { pr, .. }
            | Close { pr, .. }
            | Merge { pr, .. }
            | Browse { id: pr } => pr.as_ref().and_then(|x| x.repo.as_ref()),
        }
    }

    fn no_repo_error(&self) -> eyre::Error {
        use PrSubcommand::*;
        match &self.command {
            Search { .. } | Create { .. } => {
                eyre::eyre!("can't figure what repo to access, try specifying with `--repo`")
            }
            Checkout { .. } => {
                if git2::Repository::discover(".").is_ok() {
                    eyre::eyre!("can't figure out what repo to access, try setting a remote tracking branch")
                } else {
                    eyre::eyre!("pr checkout only works if the current directory is a git repo")
                }
            }
            View { id: pr, .. }
            | Status { id: pr, .. }
            | Comment { pr, .. }
            | Edit { pr, .. }
            | Close { pr, .. }
            | Merge { pr, .. }
            | Browse { id: pr, .. } => match pr {
                Some(pr) => eyre::eyre!(
                    "can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{}`",
                    pr.number
                    ),
                None => eyre::eyre!(
                    "can't figure out what repo to access, try specifying with `{{owner}}/{{repo}}#{{pr}}`",
                    ),
            },
        }
    }
}

pub async fn view_pr(repo: &RepoName, api: &Forgejo, id: Option<u64>) -> eyre::Result<()> {
    let crate::SpecialRender {
        dash,

        bright_red,
        bright_green,
        bright_magenta,
        yellow,
        dark_grey,
        light_grey,
        white,
        reset,
        ..
    } = crate::special_render();
    let pr = try_get_pr(repo, api, id).await?;
    let id = pr.number.ok_or_eyre("pr does not have number")? as u64;
    let repo = repo_name_from_pr(&pr)?;

    let mut additions = 0;
    let mut deletions = 0;
    let query = RepoGetPullRequestFilesQuery {
        limit: Some(u32::MAX),
        ..Default::default()
    };
    let (_, files) = api
        .repo_get_pull_request_files(repo.owner(), repo.name(), id, query)
        .await?;
    for file in files {
        additions += file.additions.unwrap_or_default();
        deletions += file.deletions.unwrap_or_default();
    }
    let title = pr
        .title
        .as_deref()
        .ok_or_else(|| eyre::eyre!("pr does not have title"))?;
    let title_no_wip = title
        .strip_prefix("WIP: ")
        .or_else(|| title.strip_prefix("WIP:"));
    let (title, is_draft) = match title_no_wip {
        Some(title) => (title, true),
        None => (title, false),
    };
    let state = pr
        .state
        .ok_or_else(|| eyre::eyre!("pr does not have state"))?;
    let is_merged = pr.merged.unwrap_or_default();
    let state = match state {
        StateType::Open if is_draft => format!("{light_grey}Draft{reset}"),
        StateType::Open => format!("{bright_green}Open{reset}"),
        StateType::Closed if is_merged => format!("{bright_magenta}Merged{reset}"),
        StateType::Closed => format!("{bright_red}Closed{reset}"),
    };
    let base = pr.base.as_ref().ok_or_eyre("pr does not have base")?;
    let base_repo = base
        .repo
        .as_ref()
        .ok_or_eyre("base does not have repo")?
        .full_name
        .as_deref()
        .ok_or_eyre("base repo does not have name")?;
    let base_name = base
        .label
        .as_deref()
        .ok_or_eyre("base does not have label")?;
    let head = pr.head.as_ref().ok_or_eyre("pr does not have head")?;
    let head_repo = head
        .repo
        .as_ref()
        .ok_or_eyre("head does not have repo")?
        .full_name
        .as_deref()
        .ok_or_eyre("head repo does not have name")?;
    let head_name = head
        .label
        .as_deref()
        .ok_or_eyre("head does not have label")?;
    let head_name = if base_repo != head_repo {
        format!("{head_repo}:{head_name}")
    } else {
        head_name.to_owned()
    };
    let user = pr
        .user
        .as_ref()
        .ok_or_else(|| eyre::eyre!("pr does not have creator"))?;
    let username = user
        .login
        .as_ref()
        .ok_or_else(|| eyre::eyre!("user does not have login"))?;
    let comments = pr.comments.unwrap_or_default();
    println!("{yellow}{title}{reset} {dark_grey}#{id}{reset}");
    println!(
        "By {white}{username}{reset} {dash} {state} {dash} {bright_green}+{additions} {bright_red}-{deletions}{reset}"
    );
    if head_name.is_empty() {
        println!("Into `{base_name}`");
    } else {
        println!("From `{head_name}` into `{base_name}`");
    }

    if let Some(body) = &pr.body {
        if !body.trim().is_empty() {
            println!();
            println!("{}", crate::markdown(body));
        }
    }
    println!();
    if comments == 1 {
        println!("1 comment");
    } else {
        println!("{comments} comments");
    }
    Ok(())
}

async fn view_pr_labels(repo: &RepoName, api: &Forgejo, pr: Option<u64>) -> eyre::Result<()> {
    let pr = try_get_pr(repo, api, pr).await?;
    let labels = pr.labels.as_deref().unwrap_or_default();
    render_label_list(&labels)?;
    Ok(())
}

pub fn render_label_list(labels: &[forgejo_api::structs::Label]) -> eyre::Result<()> {
    let SpecialRender { fancy, .. } = *crate::special_render();
    if fancy {
        let mut total_width = 0;
        for label in labels {
            let name = label.name.as_deref().unwrap_or("???").trim();
            if total_width + name.len() > 40 {
                println!();
                total_width = 0;
            }
            print!("{} ", render_label(label)?);
            total_width += name.len();
        }
        println!();
    } else {
        for label in labels {
            let name = label.name.as_deref().unwrap_or("???");
            println!("{name}");
        }
    }
    Ok(())
}

pub fn render_label(label: &forgejo_api::structs::Label) -> eyre::Result<String> {
    use std::fmt::Write;
    let mut s = String::new();
    let SpecialRender {
        black,
        white,
        reset,
        ..
    } = *crate::special_render();
    let name = label.name.as_deref().unwrap_or("???").trim();
    let color_s = label.color.as_deref().unwrap_or("FFFFFF");
    let (r, g, b) = parse_color(color_s)?;
    let text_color = if luma(r, g, b) > 0.5 { black } else { white };
    let rgb_bg = format!("\x1b[48;2;{r};{g};{b}m");
    if label.exclusive.unwrap_or_default() {
        let (r2, g2, b2) = darken(r, g, b);
        let (category, name) = name
            .split_once("/")
            .ok_or_eyre("label is exclusive but does not have slash")?;
        let rgb_bg_dark = format!("\x1b[48;2;{r2};{g2};{b2}m");
        write!(
            &mut s,
            "{rgb_bg_dark}{text_color} {category} {rgb_bg} {name} {reset}"
        )?;
    } else {
        write!(&mut s, "{rgb_bg}{text_color} {name} {reset}")?;
    }
    Ok(s)
}

fn parse_color(color: &str) -> eyre::Result<(u8, u8, u8)> {
    eyre::ensure!(color.len() == 6, "color string wrong length");
    let mut iter = color.chars();
    let mut next_digit = || {
        iter.next()
            .unwrap()
            .to_digit(16)
            .ok_or_eyre("invalid digit")
    };
    let r1 = next_digit()?;
    let r2 = next_digit()?;
    let g1 = next_digit()?;
    let g2 = next_digit()?;
    let b1 = next_digit()?;
    let b2 = next_digit()?;
    let r = ((r1 << 4) | (r2)) as u8;
    let g = ((g1 << 4) | (g2)) as u8;
    let b = ((b1 << 4) | (b2)) as u8;
    Ok((r, g, b))
}

// Thanks, wikipedia.
fn luma(r: u8, g: u8, b: u8) -> f32 {
    ((0.299 * (r as f32)) + (0.578 * (g as f32)) + (0.114 * (b as f32))) / 255.0
}

fn darken(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
    (
        ((r as f32) * 0.85) as u8,
        ((g as f32) * 0.85) as u8,
        ((b as f32) * 0.85) as u8,
    )
}

async fn view_pr_status(
    repo: &RepoName,
    api: &Forgejo,
    id: Option<u64>,
    wait: bool,
) -> eyre::Result<()> {
    if wait {
        let SpecialRender { fancy, .. } = *crate::special_render();
        let mut wait_duration = 5.0;
        let mut prev_statuses_len = 0;
        let pr_status = loop {
            let pr_status = get_pr_status(repo, api, id).await?;
            if fancy {
                if prev_statuses_len > 0 {
                    println!("\x1b[{prev_statuses_len}A");
                }
                print_pr_status(&pr_status)?;
            } else {
                print!(".");
                std::io::stdout().flush()?;
            }
            match &pr_status {
                PrStatus::Merged { .. } => break pr_status,
                PrStatus::Open {
                    commit_statuses, ..
                } => {
                    let all_finished = commit_statuses
                        .iter()
                        .flat_map(|x| x.status.as_ref())
                        .all(|state| state != "pending");
                    if all_finished {
                        break pr_status;
                    }
                    prev_statuses_len = commit_statuses.len() + 2;
                }
            }
            tokio::time::sleep(std::time::Duration::from_secs_f64(wait_duration)).await;
            wait_duration *= 1.5;
        };
        if !fancy {
            print_pr_status(&pr_status)?;
        }
    } else {
        let pr_status = get_pr_status(repo, api, id).await?;
        print_pr_status(&pr_status)?;
    }
    Ok(())
}

enum PrStatus {
    Merged {
        pr: forgejo_api::structs::PullRequest,
    },
    Open {
        pr: forgejo_api::structs::PullRequest,
        commit_statuses: Vec<forgejo_api::structs::CommitStatus>,
    },
}

async fn get_pr_status(repo: &RepoName, api: &Forgejo, id: Option<u64>) -> eyre::Result<PrStatus> {
    let pr = try_get_pr(repo, api, id).await?;
    if pr.merged.ok_or_eyre("pr merge status unknown")? {
        Ok(PrStatus::Merged { pr })
    } else {
        let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
        let query = forgejo_api::structs::RepoGetPullRequestCommitsQuery {
            page: None,
            limit: Some(u32::MAX),
            verification: Some(false),
            files: Some(false),
        };
        let (_commit_headers, commits) = api
            .repo_get_pull_request_commits(repo.owner(), repo.name(), pr_number, query)
            .await?;
        let latest_commit = commits
            .iter()
            .max_by_key(|x| x.created)
            .ok_or_eyre("no commits in pr")?;
        let sha = latest_commit
            .sha
            .as_deref()
            .ok_or_eyre("commit does not have sha")?;
        let query = forgejo_api::structs::RepoGetCombinedStatusByRefQuery {
            page: None,
            limit: Some(u32::MAX),
        };
        let combined_status = api
            .repo_get_combined_status_by_ref(repo.owner(), repo.name(), sha, query)
            .await?;
        let commit_statuses = combined_status
            .statuses
            .ok_or_eyre("combined status does not have status list")?;

        Ok(PrStatus::Open {
            pr,
            commit_statuses,
        })
    }
}

fn print_pr_status(pr_status: &PrStatus) -> eyre::Result<()> {
    let SpecialRender {
        bright_magenta,
        bright_red,
        bright_green,
        yellow,
        light_grey,
        dash,
        bullet,
        reset,
        ..
    } = *crate::special_render();
    match pr_status {
        PrStatus::Merged { pr } => {
            let merged_by = pr
                .merged_by
                .as_ref()
                .ok_or_eyre("pr not merged by anyone")?;
            let merged_by = merged_by
                .login
                .as_deref()
                .ok_or_eyre("pr merger does not have login")?;
            let merged_at = pr.merged_at.ok_or_eyre("pr does not have merge date")?;
            let date_format = time::macros::format_description!(
                "on [month repr:long] [day], [year], at [hour repr:12]:[minute] [period]"
            );
            let tz_format = time::macros::format_description!(
                "[offset_hour padding:zero sign:mandatory]:[offset_minute]"
            );
            let (merged_at, show_tz) =
                if let Ok(local_offset) = time::UtcOffset::current_local_offset() {
                    let merged_at = merged_at.to_offset(local_offset);
                    (merged_at, false)
                } else {
                    (merged_at, true)
                };
            print!(
                "{bright_magenta}Merged{reset} by {merged_by} {}",
                merged_at.format(date_format)?
            );
            if show_tz {
                print!("{}", merged_at.format(tz_format)?);
            }
            println!();
        }
        PrStatus::Open {
            pr,
            commit_statuses,
        } => {
            let state = pr.state.ok_or_eyre("pr does not have state")?;
            let is_draft = pr.title.as_deref().is_some_and(|s| s.starts_with("WIP:"));
            match state {
                StateType::Open => {
                    if is_draft {
                        println!("{light_grey}Draft{reset} {dash} Can't merge draft PR")
                    } else {
                        print!("{bright_green}Open{reset} {dash} ");
                        let mergable = pr.mergeable.ok_or_eyre("pr does not have mergable")?;
                        if mergable {
                            println!("Can be merged");
                        } else {
                            println!("{bright_red}Merge conflicts{reset}");
                        }
                    }
                }
                StateType::Closed => println!("{bright_red}Closed{reset} {dash} Reopen to merge"),
            }

            for status in commit_statuses {
                let state = status
                    .status
                    .as_deref()
                    .ok_or_eyre("status does not have status")?;
                let context = status
                    .context
                    .as_deref()
                    .ok_or_eyre("status does not have context")?;
                print!("{bullet} ");
                match state {
                    "success" => print!("{bright_green}Success{reset}"),
                    "pending" => print!("{yellow}Pending{reset}"),
                    "failure" => print!("{bright_red}Failure{reset}"),
                    _ => eyre::bail!("invalid status"),
                };
                println!(" {dash} {context}");
            }
        }
    }
    Ok(())
}

async fn edit_pr_labels(
    repo: &RepoName,
    api: &Forgejo,
    pr: Option<u64>,
    add: Vec<String>,
    rm: Vec<String>,
) -> eyre::Result<()> {
    let pr = try_get_pr(repo, api, pr).await?;
    let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
    let repo = repo_name_from_pr(&pr)?;

    let query = forgejo_api::structs::IssueListLabelsQuery {
        limit: Some(u32::MAX),
        ..Default::default()
    };
    let (_, mut labels) = api
        .issue_list_labels(repo.owner(), repo.name(), query)
        .await?;
    let query = forgejo_api::structs::OrgListLabelsQuery {
        limit: Some(u32::MAX),
        ..Default::default()
    };
    let org_labels = api
        .org_list_labels(repo.owner(), query)
        .await
        .map(|(_, x)| x)
        .unwrap_or_default();
    labels.extend(org_labels);

    let mut unknown_labels = Vec::new();

    let mut add_ids = Vec::with_capacity(add.len());
    for label_name in &add {
        let maybe_label = labels
            .iter()
            .find(|label| label.name.as_ref() == Some(label_name));
        if let Some(label) = maybe_label {
            add_ids.push(serde_json::Value::Number(
                label.id.ok_or_eyre("label does not have id")?.into(),
            ));
        } else {
            unknown_labels.push(label_name);
        }
    }

    let mut rm_ids = Vec::with_capacity(add.len());
    for label_name in &rm {
        let maybe_label = labels
            .iter()
            .find(|label| label.name.as_ref() == Some(label_name));
        if let Some(label) = maybe_label {
            rm_ids.push(label.id.ok_or_eyre("label does not have id")?);
        } else {
            unknown_labels.push(label_name);
        }
    }

    let opts = forgejo_api::structs::IssueLabelsOption {
        labels: Some(add_ids),
        updated_at: None,
    };
    api.issue_add_label(repo.owner(), repo.name(), pr_number, opts)
        .await?;
    let opts = forgejo_api::structs::DeleteLabelsOption { updated_at: None };
    for id in rm_ids {
        api.issue_remove_label(
            repo.owner(),
            repo.name(),
            pr_number,
            id as u64,
            opts.clone(),
        )
        .await?;
    }

    if !unknown_labels.is_empty() {
        if unknown_labels.len() == 1 {
            println!("'{}' doesn't exist", &unknown_labels[0]);
        } else {
            let SpecialRender { bullet, .. } = *crate::special_render();
            println!("The following labels don't exist:");
            for unknown_label in unknown_labels {
                println!("{bullet} {unknown_label}");
            }
        }
    }

    Ok(())
}

async fn create_pr(
    repo: &RepoName,
    api: &Forgejo,
    title: Option<String>,
    base: Option<String>,
    head: Option<String>,
    body: Option<String>,
    web: bool,
    agit: bool,
    remote_name: Option<&str>,
) -> eyre::Result<()> {
    let mut repo_data = api.repo_get(repo.owner(), repo.name()).await?;

    let head = match head {
        _ if agit => None,
        Some(head) => Some(head),
        None => {
            let local_repo = git2::Repository::discover(".")?;
            let head = local_repo.head()?;
            eyre::ensure!(
                head.is_branch(),
                "HEAD is not on branch, can't guess head branch"
            );

            let branch_ref = head
                .name()
                .ok_or_eyre("current branch does not have utf8 name")?;
            let upstream_remote = local_repo.branch_upstream_remote(branch_ref)?;

            let remote_name = if let Some(remote_name) = remote_name {
                remote_name
            } else {
                let upstream_name = upstream_remote
                    .as_str()
                    .ok_or_eyre("remote does not have utf8 name")?;
                upstream_name
            };

            let remote = local_repo.find_remote(remote_name)?;
            let remote_url_s = remote.url().ok_or_eyre("remote does not have utf8 url")?;
            let remote_url = url::Url::parse(remote_url_s)?;

            let clone_url = repo_data
                .clone_url
                .as_ref()
                .ok_or_eyre("repo does not have git url")?;
            let html_url = repo_data
                .html_url
                .as_ref()
                .ok_or_eyre("repo does not have html url")?;
            let ssh_url = repo_data
                .ssh_url
                .as_ref()
                .ok_or_eyre("repo does not have ssh url")?;
            eyre::ensure!(
                &remote_url == clone_url || &remote_url == html_url || &remote_url == ssh_url,
                "branch does not track that repo"
            );

            let upstream_branch = local_repo.branch_upstream_name(branch_ref)?;
            let upstream_branch = upstream_branch
                .as_str()
                .ok_or_eyre("remote branch does not have utf8 name")?;
            Some(
                upstream_branch
                    .rsplit_once("/")
                    .map(|(_, b)| b)
                    .unwrap_or(upstream_branch)
                    .to_owned(),
            )
        }
    };

    let (base, base_is_parent) = match base {
        Some(base) => match base.strip_prefix("^") {
            Some("") => (None, true),
            Some(stripped) => (Some(stripped.to_owned()), true),
            None => (Some(base), false),
        },
        None => (None, false),
    };

    let (repo_owner, repo_name, base_repo, head) = if base_is_parent {
        let parent_repo = *repo_data
            .parent
            .take()
            .ok_or_eyre("cannot create pull request upstream, there is no upstream")?;
        let parent_owner = parent_repo
            .owner
            .as_ref()
            .ok_or_eyre("parent has no owner")?
            .login
            .as_deref()
            .ok_or_eyre("parent owner has no login")?
            .to_owned();
        let parent_name = parent_repo
            .name
            .as_deref()
            .ok_or_eyre("parent has no name")?
            .to_owned();

        (
            parent_owner,
            parent_name,
            parent_repo,
            head.map(|head| format!("{}:{}", repo.owner(), head)),
        )
    } else {
        (
            repo.owner().to_owned(),
            repo.name().to_owned(),
            repo_data,
            head,
        )
    };

    let base = match base {
        Some(base) => base,
        None => base_repo
            .default_branch
            .as_deref()
            .ok_or_eyre("repo does not have default branch")?
            .to_owned(),
    };

    if web {
        // --web and --agit are mutually exclusive, so this shouldn't ever fail
        let head = head.unwrap();
        let mut pr_create_url = base_repo
            .html_url
            .clone()
            .ok_or_eyre("repo does not have html url")?;
        pr_create_url
            .path_segments_mut()
            .expect("invalid url")
            .extend(["compare", &format!("{base}...{head}")]);
        open::that_detached(pr_create_url.as_str()).wrap_err("Failed to open URL")?;
    } else {
        let title = title.ok_or_eyre("title is required")?;
        let body = match body {
            Some(body) => body,
            None => {
                let mut body = String::new();
                crate::editor(&mut body, Some("md")).await?;
                body
            }
        };
        match head {
            Some(head) => {
                let pr = api
                    .repo_create_pull_request(
                        &repo_owner,
                        &repo_name,
                        CreatePullRequestOption {
                            assignee: None,
                            assignees: None,
                            base: Some(base.to_owned()),
                            body: Some(body),
                            due_date: None,
                            head: Some(head),
                            labels: None,
                            milestone: None,
                            title: Some(title),
                        },
                    )
                    .await?;
                let number = pr
                    .number
                    .ok_or_else(|| eyre::eyre!("pr does not have number"))?;
                let title = pr
                    .title
                    .as_ref()
                    .ok_or_else(|| eyre::eyre!("pr does not have title"))?;
                println!("created pull request #{}: {}", number, title);
            }
            // no head means agit
            None => {
                let local_repo = git2::Repository::open(".")?;
                let mut git_config = local_repo.config()?;
                let clone_url = base_repo
                    .clone_url
                    .as_ref()
                    .ok_or_eyre("base repo does not have clone url")?;

                let git_auth = auth_git2::GitAuthenticator::new();

                let mut push_options = git2::PushOptions::new();

                let mut remote_callbacks = git2::RemoteCallbacks::new();
                remote_callbacks.credentials(git_auth.credentials(&git_config));
                push_options.remote_callbacks(remote_callbacks);

                let current_branch = git2::Branch::wrap(local_repo.head()?.resolve()?);
                let current_branch_name = current_branch
                    .name()?
                    .ok_or_eyre("branch name is not utf8")?;
                let topic = format!("agit-{current_branch_name}");

                push_options.remote_push_options(&[
                    &format!("topic={topic}"),
                    &format!("title={title}"),
                    &format!("description={body}"),
                ]);

                let mut remote = if let Some(remote_name) = remote_name {
                    local_repo.find_remote(remote_name)?
                } else {
                    local_repo.remote_anonymous(clone_url.as_str())?
                };

                remote.push(&[&format!("HEAD:refs/for/{base}")], Some(&mut push_options))?;

                // needed so the mutable reference later is valid
                drop(push_options);

                println!("created new PR: \"{title}\"");

                let merge_setting_name = format!("branch.{current_branch_name}.merge");
                let remote_setting_name = format!("branch.{current_branch_name}.remote");
                let cfg_push_default = git_config.get_string("push.default").ok();
                let cfg_branch_merge = git_config.get_string(&merge_setting_name).ok();
                let cfg_branch_remote = git_config.get_string(&remote_setting_name).ok();

                let topic_setting = format!("refs/for/{base}/{topic}");

                let default_is_upstream = cfg_push_default.is_some_and(|s| s == "upstream");
                let branch_merge_is_agit = cfg_branch_merge.is_some_and(|s| s == topic_setting);
                let branch_remote_is_agit = cfg_branch_remote.is_some_and(|s| s == topic_setting);
                if !default_is_upstream || !branch_merge_is_agit || !branch_remote_is_agit {
                    println!("Would you like to set the needed git config");
                    println!("items so that `git push` works for this pr?");
                    loop {
                        let response = crate::readline("(y/N/?) ").await?;
                        match response.trim() {
                            "y" | "Y" | "yes" | "Yes" => {
                                let remote = remote_name.unwrap_or(clone_url.as_str());
                                git_config.set_str("push.default", "upstream")?;
                                git_config.set_str(&merge_setting_name, &topic_setting)?;
                                git_config.set_str(&remote_setting_name, remote)?;
                                break;
                            }
                            "?" | "h" | "H" | "help" => {
                                println!("This would set the following config options:");
                                println!("  push.default = upstream");
                                println!("  branch.{current_branch_name}.merge = {topic_setting}");
                            }
                            _ => break,
                        }
                    }
                }
            }
        }
    }

    Ok(())
}

async fn merge_pr(
    repo: &RepoName,
    api: &Forgejo,
    pr: Option<u64>,
    method: Option<MergeMethod>,
    delete: bool,
    title: Option<String>,
    message: Option<Option<String>>,
) -> eyre::Result<()> {
    let repo_info = api.repo_get(repo.owner(), repo.name()).await?;

    let pr_info = try_get_pr(repo, api, pr).await?;
    let repo = repo_name_from_pr(&pr_info)?;
    let pr_html_url = pr_info
        .html_url
        .as_ref()
        .ok_or_eyre("pr does not have url")?;

    let default_merge = repo_info
        .default_merge_style
        .map(|x| x.into())
        .unwrap_or(forgejo_api::structs::MergePullRequestOptionDo::Merge);
    let merge_style = method.map(|x| x.into()).unwrap_or(default_merge);

    use forgejo_api::structs::MergePullRequestOptionDo::*;
    if title.is_some() {
        match merge_style {
            Rebase => eyre::bail!("rebase does not support commit title"),
            FastForwardOnly => eyre::bail!("ff-only does not support commit title"),
            ManuallyMerged => eyre::bail!("manually merged does not support commit title"),
            _ => (),
        }
    }
    let default_message = || format!("Reviewed-on: {pr_html_url}");
    let message = match message {
        Some(Some(s)) => s,
        Some(None) => {
            let mut body = default_message();
            crate::editor(&mut body, Some("md")).await?;
            body
        }
        None => default_message(),
    };

    let request = MergePullRequestOption {
        r#do: merge_style,
        merge_commit_id: None,
        merge_message_field: Some(message),
        merge_title_field: title,
        delete_branch_after_merge: Some(delete),
        force_merge: None,
        head_commit_id: None,
        merge_when_checks_succeed: None,
    };
    let pr_number = pr_info.number.ok_or_eyre("pr does not have number")? as u64;
    api.repo_merge_pull_request(repo.owner(), repo.name(), pr_number, request)
        .await?;

    let pr_title = pr_info
        .title
        .as_deref()
        .ok_or_eyre("pr does not have title")?;
    let pr_base = pr_info.base.as_ref().ok_or_eyre("pr does not have base")?;
    let base_label = pr_base
        .label
        .as_ref()
        .ok_or_eyre("base does not have label")?;
    println!("Merged PR #{pr_number} \"{pr_title}\" into `{base_label}`");
    Ok(())
}

async fn checkout_pr(
    repo: &RepoName,
    api: &Forgejo,
    pr: PrNumber,
    branch_name: Option<String>,
    ssh: bool,
) -> eyre::Result<()> {
    let local_repo = git2::Repository::discover(".").unwrap();

    let mut options = git2::StatusOptions::new();
    options.include_ignored(false);
    let has_no_uncommited = local_repo.statuses(Some(&mut options)).unwrap().is_empty();
    eyre::ensure!(
        has_no_uncommited,
        "Cannot checkout PR, working directory has uncommited changes"
    );

    let remote_repo = match pr {
        PrNumber::Parent(_) => {
            let mut this_repo = api.repo_get(repo.owner(), repo.name()).await?;
            let name = this_repo.full_name.as_deref().unwrap_or("???/???");
            *this_repo
                .parent
                .take()
                .ok_or_else(|| eyre::eyre!("cannot get parent repo, {name} is not a fork"))?
        }
        PrNumber::This(_) => api.repo_get(repo.owner(), repo.name()).await?,
    };

    let (repo_owner, repo_name) = repo_name_from_repo(&remote_repo)?;

    let pull_data = api
        .repo_get_pull_request(repo_owner, repo_name, pr.number())
        .await?;

    let url = crate::repo::git_url(&remote_repo, ssh)?;
    let mut remote = local_repo.remote_anonymous(url.as_str())?;
    let branch_name = branch_name.unwrap_or_else(|| {
        format!(
            "pr-{}-{}-{}",
            url.host_str().unwrap_or("unknown"),
            repo_owner,
            pr.number(),
        )
    });

    auth_git2::GitAuthenticator::new().fetch(
        &local_repo,
        &mut remote,
        &[&format!("pull/{}/head", pr.number())],
        None,
    )?;

    let reference = local_repo.find_reference("FETCH_HEAD")?.resolve()?;
    let commit = reference.peel_to_commit()?;

    let mut branch_is_new = true;
    let branch =
        if let Ok(mut branch) = local_repo.find_branch(&branch_name, git2::BranchType::Local) {
            branch_is_new = false;
            branch
                .get_mut()
                .set_target(commit.id(), "update pr branch")?;
            branch
        } else {
            local_repo.branch(&branch_name, &commit, false)?
        };
    let branch_ref = branch
        .get()
        .name()
        .ok_or_eyre("branch does not have name")?;

    local_repo.set_head(branch_ref)?;
    local_repo
        // for some reason, `.force()` is required to make it actually update
        // file contents. thank you git2 examples for noticing this too, I would
        // have pulled out so much hair figuring this out myself.
        .checkout_head(Some(git2::build::CheckoutBuilder::default().force()))
        .unwrap();

    let pr_title = pull_data.title.as_deref().ok_or_eyre("pr has no title")?;
    println!("Checked out PR #{}: {pr_title}", pr.number());
    if branch_is_new {
        println!("On new branch {branch_name}");
    } else {
        println!("Updated branch to latest commit");
    }

    Ok(())
}

async fn view_prs(
    repo: &RepoName,
    api: &Forgejo,
    query_str: Option<String>,
    labels: Option<String>,
    creator: Option<String>,
    assignee: Option<String>,
    state: Option<crate::issues::State>,
) -> eyre::Result<()> {
    let labels = labels
        .map(|s| s.split(',').map(|s| s.to_string()).collect::<Vec<_>>())
        .unwrap_or_default();
    let query = forgejo_api::structs::IssueListIssuesQuery {
        q: query_str,
        labels: Some(labels.join(",")),
        created_by: creator,
        assigned_by: assignee,
        state: state.map(|s| s.into()),
        r#type: Some(forgejo_api::structs::IssueListIssuesQueryType::Pulls),
        milestones: None,
        since: None,
        before: None,
        mentioned_by: None,
        page: None,
        limit: None,
    };
    let (_, prs) = api
        .issue_list_issues(repo.owner(), repo.name(), query)
        .await?;
    if prs.len() == 1 {
        println!("1 pull request");
    } else {
        println!("{} pull requests", prs.len());
    }
    for pr in prs {
        let number = pr
            .number
            .ok_or_else(|| eyre::eyre!("pr does not have number"))?;
        let title = pr
            .title
            .as_ref()
            .ok_or_else(|| eyre::eyre!("pr does not have title"))?;
        let user = pr
            .user
            .as_ref()
            .ok_or_else(|| eyre::eyre!("pr does not have creator"))?;
        let username = user
            .login
            .as_ref()
            .ok_or_else(|| eyre::eyre!("user does not have login"))?;
        println!("#{}: {} (by {})", number, title, username);
    }
    Ok(())
}

async fn view_diff(
    repo: &RepoName,
    api: &Forgejo,
    pr: Option<u64>,
    patch: bool,
    editor: bool,
) -> eyre::Result<()> {
    let pr = try_get_pr(repo, api, pr).await?;
    let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
    let repo = repo_name_from_pr(&pr)?;
    let diff_type = if patch { "patch" } else { "diff" };
    let diff = api
        .repo_download_pull_diff_or_patch(
            repo.owner(),
            repo.name(),
            pr_number,
            diff_type,
            forgejo_api::structs::RepoDownloadPullDiffOrPatchQuery::default(),
        )
        .await?;
    if editor {
        let mut view = diff.clone();
        crate::editor(&mut view, Some(diff_type)).await?;
        if view != diff {
            println!("changes made to the diff will not persist");
        }
    } else {
        println!("{diff}");
    }
    Ok(())
}

async fn view_pr_files(repo: &RepoName, api: &Forgejo, pr: Option<u64>) -> eyre::Result<()> {
    let pr = try_get_pr(repo, api, pr).await?;
    let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
    let repo = repo_name_from_pr(&pr)?;
    let crate::SpecialRender {
        bright_red,
        bright_green,
        reset,
        ..
    } = crate::special_render();

    let query = RepoGetPullRequestFilesQuery {
        limit: Some(u32::MAX),
        ..Default::default()
    };
    let (_, files) = api
        .repo_get_pull_request_files(repo.owner(), repo.name(), pr_number, query)
        .await?;
    let max_additions = files
        .iter()
        .map(|x| x.additions.unwrap_or_default())
        .max()
        .unwrap_or_default();
    let max_deletions = files
        .iter()
        .map(|x| x.deletions.unwrap_or_default())
        .max()
        .unwrap_or_default();

    let additions_width = max_additions.checked_ilog10().unwrap_or_default() as usize + 1;
    let deletions_width = max_deletions.checked_ilog10().unwrap_or_default() as usize + 1;

    for file in files {
        let name = file.filename.as_deref().unwrap_or("???");
        let additions = file.additions.unwrap_or_default();
        let deletions = file.deletions.unwrap_or_default();
        println!("{bright_green}+{additions:<additions_width$} {bright_red}-{deletions:<deletions_width$}{reset} {name}");
    }
    Ok(())
}

async fn view_pr_commits(
    repo: &RepoName,
    api: &Forgejo,
    pr: Option<u64>,
    oneline: bool,
) -> eyre::Result<()> {
    let pr = try_get_pr(repo, api, pr).await?;
    let pr_number = pr.number.ok_or_eyre("pr does not have number")? as u64;
    let repo = repo_name_from_pr(&pr)?;
    let query = RepoGetPullRequestCommitsQuery {
        limit: Some(u32::MAX),
        files: Some(false),
        ..Default::default()
    };
    let (_headers, commits) = api
        .repo_get_pull_request_commits(repo.owner(), repo.name(), pr_number, query)
        .await?;

    let max_additions = commits
        .iter()
        .filter_map(|x| x.stats.as_ref())
        .map(|x| x.additions.unwrap_or_default())
        .max()
        .unwrap_or_default();
    let max_deletions = commits
        .iter()
        .filter_map(|x| x.stats.as_ref())
        .map(|x| x.deletions.unwrap_or_default())
        .max()
        .unwrap_or_default();

    let additions_width = max_additions.checked_ilog10().unwrap_or_default() as usize + 1;
    let deletions_width = max_deletions.checked_ilog10().unwrap_or_default() as usize + 1;

    let crate::SpecialRender {
        bright_red,
        bright_green,
        yellow,
        reset,
        ..
    } = crate::special_render();
    for commit in commits {
        let repo_commit = commit
            .commit
            .as_ref()
            .ok_or_eyre("commit does not have commit?")?;

        let message = repo_commit.message.as_deref().unwrap_or("[no msg]");
        let name = message.lines().next().unwrap_or(message);

        let sha = commit
            .sha
            .as_deref()
            .ok_or_eyre("commit does not have sha")?;
        let short_sha = &sha[..7];

        let stats = commit
            .stats
            .as_ref()
            .ok_or_eyre("commit does not have stats")?;
        let additions = stats.additions.unwrap_or_default();
        let deletions = stats.deletions.unwrap_or_default();

        if oneline {
            println!("{yellow}{short_sha} {bright_green}+{additions:<additions_width$} {bright_red}-{deletions:<deletions_width$}{reset} {name}");
        } else {
            let author = repo_commit
                .author
                .as_ref()
                .ok_or_eyre("commit has no author")?;
            let author_name = author.name.as_deref().ok_or_eyre("author has no name")?;
            let author_email = author.email.as_deref().ok_or_eyre("author has no email")?;
            let date = commit
                .created
                .as_ref()
                .ok_or_eyre("commit as no creation date")?;

            println!("{yellow}commit {sha}{reset} ({bright_green}+{additions}{reset}, {bright_red}-{deletions}{reset})");
            println!("Author: {author_name} <{author_email}>");
            print!("Date:   ");
            let format = time::macros::format_description!("[weekday repr:short] [month repr:short] [day] [hour repr:24]:[minute]:[second] [year] [offset_hour sign:mandatory][offset_minute]");
            date.format_into(&mut std::io::stdout().lock(), format)?;
            println!();
            println!();
            for line in message.lines() {
                println!("    {line}");
            }
            println!();
        }
    }
    Ok(())
}

pub async fn browse_pr(repo: &RepoName, api: &Forgejo, id: u64) -> eyre::Result<()> {
    let pr = api
        .repo_get_pull_request(repo.owner(), repo.name(), id)
        .await?;
    let html_url = pr
        .html_url
        .as_ref()
        .ok_or_else(|| eyre::eyre!("pr does not have html_url"))?;
    open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
    Ok(())
}

async fn try_get_pr_number(
    repo: &RepoName,
    api: &Forgejo,
    number: Option<u64>,
) -> eyre::Result<(RepoName, u64)> {
    let pr = match number {
        Some(number) => (repo.clone(), number),
        None => {
            let pr = guess_pr(repo, api)
                .await
                .wrap_err("could not guess pull request number, please specify")?;
            let number = pr.number.ok_or_eyre("pr does not have number")? as u64;
            let repo = repo_name_from_pr(&pr)?;
            (repo, number)
        }
    };
    Ok(pr)
}

async fn try_get_pr(
    repo: &RepoName,
    api: &Forgejo,
    number: Option<u64>,
) -> eyre::Result<forgejo_api::structs::PullRequest> {
    let pr = match number {
        Some(number) => {
            api.repo_get_pull_request(repo.owner(), repo.name(), number)
                .await?
        }
        None => guess_pr(repo, api)
            .await
            .wrap_err("could not guess pull request number, please specify")?,
    };
    Ok(pr)
}

async fn guess_pr(
    repo: &RepoName,
    api: &Forgejo,
) -> eyre::Result<forgejo_api::structs::PullRequest> {
    let local_repo = git2::Repository::discover(".")?;
    let head = local_repo.head()?;
    eyre::ensure!(head.is_branch(), "head is not on branch");
    let local_branch = git2::Branch::wrap(head);
    let local_branch_name = local_branch.name()?.ok_or_eyre("branch name is not utf8")?;
    let config = local_repo.config()?;
    let remote_head_name = config.get_string(&format!("branch.{local_branch_name}.merge"))?;

    let maybe_agit = remote_head_name
        .strip_prefix("refs/for/")
        .and_then(|s| s.split_once("/"));

    match maybe_agit {
        Some((base, head)) => {
            let username = api
                .user_get_current()
                .await?
                .login
                .ok_or_eyre("user does not have username")?
                .to_lowercase();
            let head = format!("{username}/{head}");
            return Ok(api
                .repo_get_pull_request_by_base_head(repo.owner(), repo.name(), base, &head)
                .await?);
        }
        None => {
            let remote_head_short = remote_head_name
                .rsplit_once("/")
                .map(|(_, b)| b)
                .unwrap_or(&remote_head_name);

            let this_repo = api.repo_get(repo.owner(), repo.name()).await?;

            // check for PRs on the main branch first
            let base = this_repo
                .default_branch
                .as_deref()
                .ok_or_eyre("repo does not have default branch")?;
            if let Ok(pr) = api
                .repo_get_pull_request_by_base_head(
                    repo.owner(),
                    repo.name(),
                    base,
                    remote_head_short,
                )
                .await
            {
                return Ok(pr);
            }

            let this_full_name = this_repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            let parent_remote_head_name = format!("{this_full_name}:{remote_head_short}");

            if let Some(parent) = this_repo.parent.as_deref() {
                let (parent_owner, parent_name) = repo_name_from_repo(parent)?;
                let parent_base = this_repo
                    .default_branch
                    .as_deref()
                    .ok_or_eyre("repo does not have default branch")?;
                if let Ok(pr) = api
                    .repo_get_pull_request_by_base_head(
                        parent_owner,
                        parent_name,
                        parent_base,
                        &parent_remote_head_name,
                    )
                    .await
                {
                    return Ok(pr);
                }
            }

            // then iterate all branches
            if let Some(pr) =
                find_pr_from_branch(repo.owner(), repo.name(), api, remote_head_short).await?
            {
                return Ok(pr);
            }

            if let Some(parent) = this_repo.parent.as_deref() {
                let (parent_owner, parent_name) = repo_name_from_repo(parent)?;

                if let Some(pr) =
                    find_pr_from_branch(parent_owner, parent_name, api, &parent_remote_head_name)
                        .await?
                {
                    return Ok(pr);
                }
            }
        }
    }

    eyre::bail!("could not find PR");
}

async fn find_pr_from_branch(
    repo_owner: &str,
    repo_name: &str,
    api: &Forgejo,
    head: &str,
) -> eyre::Result<Option<forgejo_api::structs::PullRequest>> {
    let mut seen = 0;
    for page in 1.. {
        let branch_query = forgejo_api::structs::RepoListBranchesQuery {
            page: Some(page),
            limit: Some(30),
        };
        let (headers, remote_branches) = api
            .repo_list_branches(repo_owner, repo_name, branch_query)
            .await?;
        let prs = futures::future::try_join_all(
            remote_branches
                .into_iter()
                .map(|branch| check_branch_pair(repo_owner, repo_name, api, branch, head)),
        )
        .await?;
        seen += prs.len();
        for pr in prs {
            if pr.is_some() {
                return Ok(pr);
            }
        }
        if seen >= headers.x_total_count.unwrap_or_default() as usize {
            break;
        }
    }
    Ok(None)
}

async fn check_branch_pair(
    repo_owner: &str,
    repo_name: &str,
    api: &Forgejo,
    base: forgejo_api::structs::Branch,
    head: &str,
) -> eyre::Result<Option<forgejo_api::structs::PullRequest>> {
    let base_name = base
        .name
        .as_deref()
        .ok_or_eyre("remote branch does not have name")?;
    match api
        .repo_get_pull_request_by_base_head(repo_owner, repo_name, base_name, head)
        .await
    {
        Ok(pr) => Ok(Some(pr)),
        Err(_) => Ok(None),
    }
}

fn repo_name_from_repo(repo: &forgejo_api::structs::Repository) -> eyre::Result<(&str, &str)> {
    let owner = repo
        .owner
        .as_ref()
        .ok_or_eyre("repo does not have owner")?
        .login
        .as_deref()
        .ok_or_eyre("repo owner does not have name")?;
    let name = repo.name.as_deref().ok_or_eyre("repo does not have name")?;
    Ok((owner, name))
}

fn repo_name_from_pr(pr: &forgejo_api::structs::PullRequest) -> eyre::Result<RepoName> {
    let base_branch = pr.base.as_ref().ok_or_eyre("pr does not have base")?;
    let repo = base_branch
        .repo
        .as_ref()
        .ok_or_eyre("branch does not have repo")?;
    let (owner, name) = repo_name_from_repo(repo)?;
    let repo_name = RepoName::new(owner.to_owned(), name.to_owned());
    Ok(repo_name)
}

//async fn guess_pr(
//    repo: &RepoName,
//    api: &Forgejo,
//) -> eyre::Result<forgejo_api::structs::PullRequest> {
//    let local_repo = git2::Repository::open(".")?;
//    let head_id = local_repo.head()?.peel_to_commit()?.id();
//    let sha = oid_to_string(head_id);
//    let pr = api
//        .repo_get_commit_pull_request(repo.owner(), repo.name(), &sha)
//        .await?;
//    Ok(pr)
//}
//
//fn oid_to_string(oid: git2::Oid) -> String {
//    let mut s = String::with_capacity(40);
//    for byte in oid.as_bytes() {
//        s.push(
//            char::from_digit((byte & 0xF) as u32, 16).expect("every nibble is a valid hex digit"),
//        );
//        s.push(
//            char::from_digit(((byte >> 4) & 0xF) as u32, 16)
//                .expect("every nibble is a valid hex digit"),
//        );
//    }
//    s
//}
07070100000018000081A4000000000000000000000001681904CF000049DB000000000000000000000000000000000000002100000000forgejo-cli-0.3.0/src/release.rsuse clap::{Args, Subcommand};
use eyre::{bail, eyre, Context, OptionExt};
use forgejo_api::{
    structs::{RepoCreateReleaseAttachmentQuery, RepoListReleasesQuery},
    Forgejo,
};
use tokio::io::AsyncWriteExt;

use crate::{
    keys::KeyInfo,
    repo::{RepoArg, RepoInfo, RepoName},
    SpecialRender,
};

#[derive(Args, Clone, Debug)]
pub struct ReleaseCommand {
    /// The local git remote that points to the repo to operate on.
    #[clap(long, short = 'R')]
    remote: Option<String>,
    /// The name of the repository to operate on.
    #[clap(long, short)]
    repo: Option<RepoArg>,
    #[clap(subcommand)]
    command: ReleaseSubcommand,
}

#[derive(Subcommand, Clone, Debug)]
pub enum ReleaseSubcommand {
    /// Create a new release
    Create {
        name: String,
        #[clap(long, short = 'T')]
        /// Create a new cooresponding tag for this release. Defaults to release's name.
        create_tag: Option<Option<String>>,
        #[clap(long, short = 't')]
        /// Pre-existing tag to use
        ///
        /// If you need to create a new tag for this release, use `--create-tag`
        tag: Option<String>,
        #[clap(
            long,
            short,
            help = "Include a file as an attachment",
            long_help = "Include a file as an attachment
        
`--attach=<FILE>` will set the attachment's name to the file name
`--attach=<FILE>:<ASSET>` will use the provided name for the attachment"
        )]
        attach: Vec<String>,
        #[clap(long, short)]
        /// Text of the release body.
        ///
        /// Using this flag without an argument will open your editor.
        body: Option<Option<String>>,
        #[clap(long, short = 'B')]
        branch: Option<String>,
        #[clap(long, short)]
        draft: bool,
        #[clap(long, short)]
        prerelease: bool,
    },
    /// Edit a release's info
    Edit {
        name: String,
        #[clap(long, short = 'n')]
        rename: Option<String>,
        #[clap(long, short = 't')]
        /// Corresponding tag for this release.
        tag: Option<String>,
        #[clap(long, short)]
        /// Text of the release body.
        ///
        /// Using this flag without an argument will open your editor.
        body: Option<Option<String>>,
        #[clap(long, short)]
        draft: Option<bool>,
        #[clap(long, short)]
        prerelease: Option<bool>,
    },
    /// Delete a release
    Delete {
        name: String,
        #[clap(long, short = 't')]
        by_tag: bool,
    },
    /// List all the releases on a repo
    List {
        #[clap(long, short = 'p')]
        include_prerelease: bool,
        #[clap(long, short = 'd')]
        include_draft: bool,
    },
    /// View a release's info
    View {
        name: String,
        #[clap(long, short = 't')]
        by_tag: bool,
    },
    /// Open a release in your browser
    Browse { name: Option<String> },
    /// Commands on a release's attached files
    #[clap(subcommand)]
    Asset(AssetCommand),
}

#[derive(Subcommand, Clone, Debug)]
pub enum AssetCommand {
    /// Create a new attachment on a release
    Create {
        release: String,
        path: std::path::PathBuf,
        name: Option<String>,
    },
    /// Remove an attachment from a release
    Delete { release: String, asset: String },
    /// Download an attached file
    ///
    /// Use `source.zip` or `source.tar.gz` to download the repo archive
    Download {
        release: String,
        asset: String,
        #[clap(long, short)]
        output: Option<std::path::PathBuf>,
    },
}

impl ReleaseCommand {
    pub async fn run(self, keys: &mut KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> {
        let repo = RepoInfo::get_current(
            remote_name,
            self.repo.as_ref(),
            self.remote.as_deref(),
            &keys,
        )?;
        let api = keys.get_api(repo.host_url()).await?;
        let repo = repo
            .name()
            .ok_or_eyre("couldn't get repo name, try specifying with --repo")?;
        match self.command {
            ReleaseSubcommand::Create {
                name,
                create_tag,
                tag,
                attach,
                body,
                branch,
                draft,
                prerelease,
            } => {
                create_release(
                    repo, &api, name, create_tag, tag, attach, body, branch, draft, prerelease,
                )
                .await?
            }
            ReleaseSubcommand::Edit {
                name,
                rename,
                tag,
                body,
                draft,
                prerelease,
            } => edit_release(repo, &api, name, rename, tag, body, draft, prerelease).await?,
            ReleaseSubcommand::Delete { name, by_tag } => {
                delete_release(repo, &api, name, by_tag).await?
            }
            ReleaseSubcommand::List {
                include_prerelease,
                include_draft,
            } => list_releases(repo, &api, include_prerelease, include_draft).await?,
            ReleaseSubcommand::View { name, by_tag } => {
                view_release(repo, &api, name, by_tag).await?
            }
            ReleaseSubcommand::Browse { name } => browse_release(repo, &api, name).await?,
            ReleaseSubcommand::Asset(subcommand) => match subcommand {
                AssetCommand::Create {
                    release,
                    path,
                    name,
                } => create_asset(repo, &api, release, path, name).await?,
                AssetCommand::Delete { release, asset } => {
                    delete_asset(repo, &api, release, asset).await?
                }
                AssetCommand::Download {
                    release,
                    asset,
                    output,
                } => download_asset(repo, &api, release, asset, output).await?,
            },
        }
        Ok(())
    }
}

async fn create_release(
    repo: &RepoName,
    api: &Forgejo,
    name: String,
    create_tag: Option<Option<String>>,
    tag: Option<String>,
    attachments: Vec<String>,
    body: Option<Option<String>>,
    branch: Option<String>,
    draft: bool,
    prerelease: bool,
) -> eyre::Result<()> {
    let tag_name = match (tag, create_tag) {
        (None, None) => bail!("must select tag with `--tag` or `--create-tag`"),
        (Some(tag), None) => tag,
        (None, Some(tag)) => {
            let tag = tag.unwrap_or_else(|| name.clone());
            let opt = forgejo_api::structs::CreateTagOption {
                message: None,
                tag_name: tag.clone(),
                target: branch,
            };
            api.repo_create_tag(repo.owner(), repo.name(), opt).await?;
            tag
        }
        (Some(_), Some(_)) => {
            bail!("`--tag` and `--create-tag` are mutually exclusive; please pick just one")
        }
    };

    let body = match body {
        Some(Some(body)) => Some(body),
        Some(None) => {
            let mut s = String::new();
            crate::editor(&mut s, Some("md")).await?;
            Some(s)
        }
        None => None,
    };

    let release_opt = forgejo_api::structs::CreateReleaseOption {
        hide_archive_links: None,
        body,
        draft: Some(draft),
        name: Some(name.clone()),
        prerelease: Some(prerelease),
        tag_name,
        target_commitish: None,
    };
    let release = api
        .repo_create_release(repo.owner(), repo.name(), release_opt)
        .await?;

    for attachment in attachments {
        let (file, asset) = match attachment.split_once(':') {
            Some((file, asset)) => (std::path::Path::new(file), asset),
            None => {
                let file = std::path::Path::new(&attachment);
                let asset = file
                    .file_name()
                    .ok_or_else(|| eyre!("{attachment} does not have a file name"))?
                    .to_str()
                    .unwrap();
                (file, asset)
            }
        };
        let query = RepoCreateReleaseAttachmentQuery {
            name: Some(asset.into()),
        };
        let id = release
            .id
            .ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
        api.repo_create_release_attachment(
            repo.owner(),
            repo.name(),
            id,
            Some(tokio::fs::read(file).await?),
            None,
            query,
        )
        .await?;
    }

    println!("Created release {name}");

    Ok(())
}

async fn edit_release(
    repo: &RepoName,
    api: &Forgejo,
    name: String,
    rename: Option<String>,
    tag: Option<String>,
    body: Option<Option<String>>,
    draft: Option<bool>,
    prerelease: Option<bool>,
) -> eyre::Result<()> {
    let release = find_release(repo, api, &name).await?;
    let body = match body {
        Some(Some(body)) => Some(body),
        Some(None) => {
            let mut s = release
                .body
                .clone()
                .ok_or_else(|| eyre::eyre!("release does not have body"))?;
            crate::editor(&mut s, Some("md")).await?;
            Some(s)
        }
        None => None,
    };
    let release_edit = forgejo_api::structs::EditReleaseOption {
        hide_archive_links: None,
        name: rename,
        tag_name: tag,
        body,
        draft,
        prerelease,
        target_commitish: None,
    };
    let id = release
        .id
        .ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
    api.repo_edit_release(repo.owner(), repo.name(), id, release_edit)
        .await?;
    Ok(())
}

async fn list_releases(
    repo: &RepoName,
    api: &Forgejo,
    prerelease: bool,
    draft: bool,
) -> eyre::Result<()> {
    let query = forgejo_api::structs::RepoListReleasesQuery {
        q: None,
        pre_release: Some(prerelease),
        draft: Some(draft),
        page: None,
        limit: None,
    };
    let (_, releases) = api
        .repo_list_releases(repo.owner(), repo.name(), query)
        .await?;
    for release in releases {
        let name = release
            .name
            .as_ref()
            .ok_or_else(|| eyre::eyre!("release does not have name"))?;
        let draft = release
            .draft
            .as_ref()
            .ok_or_else(|| eyre::eyre!("release does not have draft"))?;
        let prerelease = release
            .prerelease
            .as_ref()
            .ok_or_else(|| eyre::eyre!("release does not have prerelease"))?;
        print!("{}", name);
        match (draft, prerelease) {
            (false, false) => (),
            (true, false) => print!(" (draft)"),
            (false, true) => print!(" (prerelease)"),
            (true, true) => print!(" (draft, prerelease)"),
        }
        println!();
    }
    Ok(())
}

async fn view_release(
    repo: &RepoName,
    api: &Forgejo,
    name: String,
    by_tag: bool,
) -> eyre::Result<()> {
    let release = if by_tag {
        api.repo_get_release_by_tag(repo.owner(), repo.name(), &name)
            .await?
    } else {
        find_release(repo, api, &name).await?
    };
    let name = release
        .name
        .as_ref()
        .ok_or_else(|| eyre::eyre!("release does not have name"))?;
    let author = release
        .author
        .as_ref()
        .ok_or_else(|| eyre::eyre!("release does not have author"))?;
    let login = author
        .login
        .as_ref()
        .ok_or_else(|| eyre::eyre!("autho does not have login"))?;
    let created_at = release
        .created_at
        .ok_or_else(|| eyre::eyre!("release does not have created_at"))?;
    println!("{}", name);
    print!("By {} on ", login);
    created_at.format_into(
        &mut std::io::stdout(),
        &time::format_description::well_known::Rfc2822,
    )?;
    println!();
    let SpecialRender { bullet, .. } = crate::special_render();
    let body = release
        .body
        .as_ref()
        .ok_or_else(|| eyre::eyre!("release does not have body"))?;
    if !body.is_empty() {
        println!();
        println!("{}", crate::markdown(body));
        println!();
    }
    let assets = release
        .assets
        .as_ref()
        .ok_or_else(|| eyre::eyre!("release does not have assets"))?;
    if !assets.is_empty() {
        println!("{} assets", assets.len() + 2);
        for asset in assets {
            let name = asset
                .name
                .as_ref()
                .ok_or_else(|| eyre::eyre!("asset does not have name"))?;
            println!("{bullet} {}", name);
        }
        println!("{bullet} source.zip");
        println!("{bullet} source.tar.gz");
    }
    Ok(())
}

async fn browse_release(repo: &RepoName, api: &Forgejo, name: Option<String>) -> eyre::Result<()> {
    match name {
        Some(name) => {
            let release = find_release(repo, api, &name).await?;
            let html_url = release
                .html_url
                .as_ref()
                .ok_or_else(|| eyre::eyre!("release does not have html_url"))?;
            open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
        }
        None => {
            let repo_data = api.repo_get(repo.owner(), repo.name()).await?;
            let mut html_url = repo_data
                .html_url
                .clone()
                .ok_or_else(|| eyre::eyre!("repository does not have html_url"))?;
            html_url.path_segments_mut().unwrap().push("releases");
            open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
        }
    }
    Ok(())
}

async fn create_asset(
    repo: &RepoName,
    api: &Forgejo,
    release: String,
    file: std::path::PathBuf,
    asset: Option<String>,
) -> eyre::Result<()> {
    let (file, asset) = match asset {
        Some(ref asset) => (&*file, &**asset),
        None => {
            let asset = file
                .file_name()
                .ok_or_else(|| eyre!("{} does not have a file name", file.display()))?
                .to_str()
                .unwrap();
            (&*file, asset)
        }
    };
    let id = find_release(repo, api, &release)
        .await?
        .id
        .ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
    let query = RepoCreateReleaseAttachmentQuery {
        name: Some(asset.to_owned()),
    };
    api.repo_create_release_attachment(
        repo.owner(),
        repo.name(),
        id,
        Some(tokio::fs::read(file).await?),
        None,
        query,
    )
    .await?;

    println!("Added attachment `{}` to {}", asset, release);

    Ok(())
}

async fn delete_asset(
    repo: &RepoName,
    api: &Forgejo,
    release_name: String,
    asset_name: String,
) -> eyre::Result<()> {
    let release = find_release(repo, api, &release_name).await?;
    let assets = release
        .assets
        .as_ref()
        .ok_or_else(|| eyre::eyre!("release does not have assets"))?;
    let asset = assets
        .iter()
        .find(|a| a.name.as_ref() == Some(&asset_name))
        .ok_or_else(|| eyre!("asset not found"))?;
    let release_id = release
        .id
        .ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
    let asset_id = asset
        .id
        .ok_or_else(|| eyre::eyre!("asset does not have id"))? as u64;
    api.repo_delete_release_attachment(repo.owner(), repo.name(), release_id, asset_id)
        .await?;
    println!("Removed attachment `{}` from {}", asset_name, release_name);
    Ok(())
}

async fn download_asset(
    repo: &RepoName,
    api: &Forgejo,
    release: String,
    asset: String,
    output: Option<std::path::PathBuf>,
) -> eyre::Result<()> {
    let release = find_release(repo, api, &release).await?;
    let file = match &*asset {
        "source.zip" => {
            let tag_name = release
                .tag_name
                .as_ref()
                .ok_or_else(|| eyre::eyre!("release does not have tag_name"))?;
            api.repo_get_archive(repo.owner(), repo.name(), &format!("{}.zip", tag_name))
                .await?
        }
        "source.tar.gz" => {
            let tag_name = release
                .tag_name
                .as_ref()
                .ok_or_else(|| eyre::eyre!("release does not have tag_name"))?;
            api.repo_get_archive(repo.owner(), repo.name(), &format!("{}.tar.gz", tag_name))
                .await?
        }
        name => {
            let assets = release
                .assets
                .as_ref()
                .ok_or_else(|| eyre::eyre!("release does not have assets"))?;
            let asset = assets
                .iter()
                .find(|a| a.name.as_deref() == Some(name))
                .ok_or_else(|| eyre!("asset not found"))?;
            let release_id = release
                .id
                .ok_or_else(|| eyre::eyre!("release does not have id"))?
                as u64;
            let asset_id = asset
                .id
                .ok_or_else(|| eyre::eyre!("asset does not have id"))?
                as u64;
            api.download_release_attachment(repo.owner(), repo.name(), release_id, asset_id)
                .await?
                .to_vec()
        }
    };
    let real_output = output
        .as_deref()
        .unwrap_or_else(|| std::path::Path::new(&asset));
    tokio::fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(real_output)
        .await?
        .write_all(file.as_ref())
        .await?;

    if output.is_some() {
        println!("Downloaded {asset} into {}", real_output.display());
    } else {
        println!("Downloaded {asset}");
    }

    Ok(())
}

async fn find_release(
    repo: &RepoName,
    api: &Forgejo,
    name: &str,
) -> eyre::Result<forgejo_api::structs::Release> {
    let query = RepoListReleasesQuery {
        q: None,
        draft: None,
        pre_release: None,
        page: None,
        limit: None,
    };
    let (_, mut releases) = api
        .repo_list_releases(repo.owner(), repo.name(), query)
        .await?;
    let idx = releases
        .iter()
        .position(|r| r.name.as_deref() == Some(name))
        .ok_or_else(|| eyre!("release not found"))?;
    Ok(releases.swap_remove(idx))
}

async fn delete_release(
    repo: &RepoName,
    api: &Forgejo,
    name: String,
    by_tag: bool,
) -> eyre::Result<()> {
    if by_tag {
        api.repo_delete_release_by_tag(repo.owner(), repo.name(), &name)
            .await?;
    } else {
        let id = find_release(repo, api, &name)
            .await?
            .id
            .ok_or_else(|| eyre::eyre!("release does not have id"))? as u64;
        api.repo_delete_release(repo.owner(), repo.name(), id)
            .await?;
    }
    Ok(())
}
07070100000019000081A4000000000000000000000001681904CF0000927C000000000000000000000000000000000000001E00000000forgejo-cli-0.3.0/src/repo.rsuse std::{io::Write, path::PathBuf, str::FromStr};

use clap::{Args, Subcommand};
use eyre::{eyre, Context, OptionExt, Result};
use forgejo_api::{structs::CreateRepoOption, Forgejo};
use ssh2_config::ParseRule;
use url::Url;

use crate::SpecialRender;

pub struct RepoInfo {
    url: Url,
    name: Option<RepoName>,
    remote_name: Option<String>,
}

impl RepoInfo {
    pub fn get_current(
        host: Option<&str>,
        repo: Option<&RepoArg>,
        remote: Option<&str>,
        keys: &crate::keys::KeyInfo,
    ) -> eyre::Result<Self> {
        // l = domain/owner/name
        // s = owner/name
        // x = is present
        // i = found locally by git
        //
        // | repo | host | remote | ans-host | ans-repo |
        // |------|------|--------|----------|----------|
        // | l    | x    | x      | repo     | repo     |
        // | l    | x    | i      | repo     | repo     |
        // | l    | x    |        | repo     | repo     |
        // | l    |      | x      | repo     | repo     |
        // | l    |      | i      | repo     | repo     |
        // | l    |      |        | repo     | repo     |
        // | s    | x    | x      | host     | repo     |
        // | s    | x    | i      | host     | repo     |
        // | s    | x    |        | host     | repo     |
        // | s    |      | x      | remote   | repo     |
        // | s    |      | i      | remote   | repo     |
        // | s    |      |        | err      | repo     |
        // |      | x    | x      | remote   | remote   |
        // |      | x    | i      | host     | ?remote  |
        // |      | x    |        | host     | none     |
        // |      |      | x      | remote   | remote   |
        // |      |      | i      | remote   | remote   |
        // |      |      |        | err      | remote   |

        let mut repo_url: Option<Url> = None;
        let mut repo_name: Option<RepoName> = None;

        if let Some(repo) = repo {
            if let Some(host) = &repo.host {
                repo_url = Url::parse(host)
                    .ok()
                    .filter(|x| !x.cannot_be_a_base())
                    .or_else(|| Url::parse(&format!("https://{host}/")).ok())
                    .map(|url| keys.deref_alias(url))
            }
            repo_name = Some(RepoName {
                owner: repo.owner.clone(),
                name: repo.name.clone(),
            });
        }

        let repo_url = repo_url;
        let repo_name = repo_name;

        let host_url = host.and_then(|host| {
            Url::parse(host)
                .ok()
                .filter(|x| !x.cannot_be_a_base())
                .or_else(|| Url::parse(&format!("https://{host}/")).ok())
                .map(|url| keys.deref_alias(url))
        });

        let mut final_remote_name = None;

        let (remote_url, remote_repo_name) = {
            let mut out = (None, None);
            if let Ok(local_repo) = git2::Repository::discover(".") {
                let mut name = remote.map(|s| s.to_owned());

                // if there's only one remote, use that
                if name.is_none() {
                    let all_remotes = local_repo.remotes()?;
                    if all_remotes.len() == 1 {
                        if let Some(remote_name) = all_remotes.get(0) {
                            name = Some(remote_name.to_owned());
                        }
                    }
                }

                // if the current branch is tracking a remote branch, use that remote
                if name.is_none() {
                    let head = local_repo.head()?;
                    let branch_name = head.name().ok_or_eyre("branch name not UTF-8")?;

                    if let Ok(remote_name) = local_repo.branch_upstream_remote(branch_name) {
                        let remote_name_s =
                            remote_name.as_str().ok_or_eyre("remote name invalid")?;

                        if let Some(host_url) = &host_url {
                            let remote = local_repo.find_remote(remote_name_s)?;
                            let url_s = std::str::from_utf8(remote.url_bytes())?;
                            let url = keys.deref_alias(crate::ssh_url_parse(url_s)?);

                            if crate::host_with_port(&url) == crate::host_with_port(host_url) {
                                name = Some(remote_name_s.to_owned());
                            }
                        } else {
                            name = Some(remote_name_s.to_owned());
                        }
                    }
                }

                // if there's a remote whose host url matches the one
                // specified with `--host`, use that
                //
                // This is different than using `--host` itself, since this
                // will include the repo name, which `--host` can't do.
                if name.is_none() {
                    if let Some(host_url) = &host_url {
                        let all_remotes = local_repo.remotes()?;
                        for remote_name in all_remotes.iter() {
                            let Some(remote_name) = remote_name else {
                                continue;
                            };
                            let remote = local_repo.find_remote(remote_name)?;

                            if let Some(url) = remote.url() {
                                let url = keys.deref_alias(crate::ssh_url_parse(url)?);
                                let (url, _) = url_strip_repo_name(url)?;
                                if crate::host_with_port(&url) == crate::host_with_port(&url)
                                    && url.path() == host_url.path()
                                {
                                    name = Some(remote_name.to_owned());
                                    break;
                                }
                            }
                        }
                    }
                }

                if let Some(name) = name {
                    if let Ok(remote) = local_repo.find_remote(&name) {
                        let url_s = std::str::from_utf8(remote.url_bytes())?;
                        let url = keys.deref_alias(crate::ssh_url_parse(url_s)?);
                        let (url, repo_name) = url_strip_repo_name(url)?;

                        out = (Some(url), Some(repo_name));

                        final_remote_name = Some(name);
                    }
                }
            } else {
                eyre::ensure!(remote.is_none(), "remote specified but no git repo found");
            }
            out
        };

        let (url, name) = if repo_url.is_some() {
            (repo_url, repo_name)
        } else if repo_name.is_some() {
            (host_url.or(remote_url), repo_name)
        } else if remote.is_some() {
            (remote_url, remote_repo_name)
        } else if host_url.is_none() || remote_url == host_url {
            (remote_url, remote_repo_name)
        } else {
            (host_url, None)
        };

        let url = url.or_else(fallback_host).map(|url| {
            let mut url = match url.scheme() {
                "http" | "https" => url,
                _ => url::Url::parse(&format!("https{}", &url[url::Position::AfterScheme..]))
                    .expect("should always be valid"),
            };
            url.set_username("").expect("shouldn't fail");
            url
        });

        let info = match (url, name) {
            (Some(url), name) => RepoInfo {
                url,
                name,
                remote_name: final_remote_name,
            },
            (None, Some(_)) => eyre::bail!("cannot find repo, no host specified"),
            (None, None) => eyre::bail!("no repo info specified"),
        };

        Ok(info)
    }

    pub fn name(&self) -> Option<&RepoName> {
        self.name.as_ref()
    }

    pub fn host_url(&self) -> &Url {
        &self.url
    }

    pub fn remote_name(&self) -> Option<&str> {
        self.remote_name.as_deref()
    }
}

fn fallback_host() -> Option<Url> {
    if let Some(envvar) = std::env::var_os("FJ_FALLBACK_HOST") {
        let out = envvar.to_str().and_then(|x| x.parse::<Url>().ok());
        if out.is_none() {
            println!("warn: `FJ_FALLBACK_HOST` is not set to a valid url");
        }
        out
    } else {
        None
    }
}

fn url_strip_repo_name(mut url: Url) -> eyre::Result<(Url, RepoName)> {
    let mut iter = url
        .path_segments()
        .ok_or_eyre("repo url cannot be a base")?
        .rev();

    let name = iter.next().ok_or_eyre("repo url too short")?;
    let name = name.strip_suffix(".git").unwrap_or(name).to_owned();

    let owner = iter.next().ok_or_eyre("repo url too short")?.to_owned();

    // Remove the username and repo name from the url
    url.path_segments_mut()
        .map_err(|_| eyre!("repo url cannot be a base"))?
        .pop()
        .pop();

    Ok((url, RepoName { owner, name }))
}

#[derive(Clone, Debug)]
pub struct RepoName {
    owner: String,
    name: String,
}

impl RepoName {
    pub fn new(owner: String, name: String) -> Self {
        Self { owner, name }
    }
    pub fn owner(&self) -> &str {
        &self.owner
    }

    pub fn name(&self) -> &str {
        &self.name
    }
}

#[derive(Debug, Clone)]
pub struct RepoArg {
    host: Option<String>,
    owner: String,
    name: String,
}

impl std::fmt::Display for RepoArg {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match &self.host {
            Some(host) => write!(f, "{host}/{}/{}", self.owner, self.name),
            None => write!(f, "{}/{}", self.owner, self.name),
        }
    }
}

impl FromStr for RepoArg {
    type Err = RepoArgError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let (head, name) = s.rsplit_once("/").ok_or(RepoArgError::NoOwner)?;
        let name = name.strip_suffix(".git").unwrap_or(name);
        let (host, owner) = match head.rsplit_once("/") {
            Some((host, owner)) => (Some(host), owner),
            None => (None, head),
        };
        Ok(Self {
            host: host.map(|s| s.to_owned()),
            owner: owner.to_owned(),
            name: name.to_owned(),
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoArgError {
    NoOwner,
}

impl std::error::Error for RepoArgError {}

impl std::fmt::Display for RepoArgError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RepoArgError::NoOwner => {
                write!(f, "repo name should be in the format [HOST/]OWNER/NAME")
            }
        }
    }
}

#[derive(Args, Clone, Debug)]
pub struct RepoCreateArgs {
    pub repo: String,

    // flags
    #[clap(long, short)]
    pub description: Option<String>,
    #[clap(long, short = 'P')]
    pub private: bool,
    /// Creates a new remote with the given name for the new repo
    #[clap(long, short)]
    pub remote: Option<String>,
    /// Pushes the current branch to the default branch on the new repo.
    /// Implies `--remote=origin` (setting remote manually overrides this)
    #[clap(long, short)]
    pub push: bool,
    /// Use SSH for the new remote instead of HTTP(S)
    #[clap(long, short = 'S')]
    pub ssh: Option<Option<bool>>,
}

#[derive(Subcommand, Clone, Debug)]
pub enum RepoCommand {
    /// Creates a new repository
    Create {
        #[clap(flatten)]
        args: RepoCreateArgs,
    },
    /// Fork a repository onto your account
    Fork {
        repo: RepoArg,
        #[clap(long)]
        name: Option<String>,
        #[clap(long, short = 'R')]
        remote: Option<String>,
    },
    Migrate {
        /// URL of the repo to migrate
        repo: String,
        /// Name of the new mirror
        name: String,
        /// Whether to mirror the repo instead of migrating it
        #[clap(long, short)]
        mirror: bool,
        /// Whether the new migration should be private
        #[clap(long, short)]
        private: bool,
        /// Comma-separated list of items to include. Defaults to nothing but git data.
        ///
        /// These are `lfs`, `wiki`, `issues`, `prs`, `milestones`, `labels`, and `releases`.
        /// You can use `all` to include everything.
        #[clap(long, short)]
        include: Option<MigrateInclude>,
        /// The URL to fetch LFS files from
        #[clap(long, short = 'L')]
        lfs_endpoint: Option<url::Url>,
        /// The type of Git service the original repo is on. Defaults to `git`
        #[clap(long, short)]
        service: Option<MigrateService>,
        /// If enabled, will read an access token in from stdin to use for fetching.
        ///
        /// Mutually exclusive with `--login`
        #[clap(long, short)]
        token: bool,
        /// If enabled, will read a username and password from stdin to use for fetching.
        ///
        /// Mutually exclusive with `--token`.
        ///
        /// This is not recommended, `--token` should be used instead whenever possible.
        #[clap(long, short)]
        login: bool,
    },
    /// View a repo's info
    View {
        name: Option<RepoArg>,
        #[clap(long, short = 'R')]
        remote: Option<String>,
    },
    /// View a repo's README
    Readme {
        name: Option<RepoArg>,
        #[clap(long, short = 'R')]
        remote: Option<String>,
    },
    /// Clone a repo's code locally
    Clone {
        repo: RepoArg,
        path: Option<PathBuf>,
        /// Clone the repo over SSH instead of HTTP(S)
        #[clap(long, short = 'S')]
        ssh: Option<Option<bool>>,
    },
    /// Add a star to a repo
    Star { repo: RepoArg },
    /// Take away a star from a repo
    Unstar { repo: RepoArg },
    /// Delete a repository
    ///
    /// This cannot be undone!
    Delete { repo: RepoArg },
    /// Open a repository's page in your browser
    Browse {
        name: Option<RepoArg>,
        #[clap(long, short = 'R')]
        remote: Option<String>,
    },
}

impl RepoCommand {
    pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
        match self {
            RepoCommand::Create {
                args:
                    RepoCreateArgs {
                        repo,

                        description,
                        private,
                        remote,
                        push,
                        ssh,
                    },
            } => {
                let host = RepoInfo::get_current(host_name, None, None, &keys)?;
                let api = keys.get_api(host.host_url()).await?;
                let url_host = crate::host_with_port(&host.host_url());
                let ssh = ssh
                    .unwrap_or(Some(keys.default_ssh.contains(url_host)))
                    .unwrap_or(true);
                create_repo(&api, None, repo, description, private, remote, push, ssh).await?;
            }
            RepoCommand::Fork { repo, name, remote } => {
                fn strip(s: &str) -> &str {
                    let no_scheme = s
                        .strip_prefix("https://")
                        .or_else(|| s.strip_prefix("http://"))
                        .unwrap_or(s);
                    let no_trailing_slash = no_scheme.strip_suffix("/").unwrap_or(no_scheme);
                    no_trailing_slash
                }
                if let (Some(a), Some(b)) = (repo.host.as_deref(), host_name) {
                    if strip(a) != strip(b) {
                        eyre::bail!("conflicting hosts {a} and {b}. please only specify one");
                    }
                }

                let repo_info =
                    RepoInfo::get_current(host_name, Some(&repo), remote.as_deref(), &keys)?;
                let api = keys.get_api(repo_info.host_url()).await?;
                let repo = repo_info
                    .name()
                    .ok_or_eyre("couldn't get repo name, please specify")?;
                fork_repo(&api, repo, name).await?
            }
            RepoCommand::Migrate {
                repo,
                name,
                mirror,
                private,
                include,
                lfs_endpoint,
                service,
                token,
                login,
            } => {
                let current_repo = RepoInfo::get_current(host_name, None, None, &keys)?;
                let api = keys.get_api(current_repo.host_url()).await?;
                migrate_repo(
                    &api,
                    repo,
                    name,
                    mirror,
                    private,
                    include,
                    lfs_endpoint,
                    service,
                    token,
                    login,
                )
                .await?
            }
            RepoCommand::View { name, remote } => {
                let repo =
                    RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref(), &keys)?;
                let api = keys.get_api(repo.host_url()).await?;
                let repo = repo
                    .name()
                    .ok_or_eyre("couldn't get repo name, please specify")?;
                view_repo(&api, repo).await?
            }
            RepoCommand::Readme { name, remote } => {
                let repo =
                    RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref(), &keys)?;
                let api = keys.get_api(repo.host_url()).await?;
                let repo = repo
                    .name()
                    .ok_or_eyre("couldn't get repo name, please specify")?;
                view_repo_readme(&api, repo).await?
            }
            RepoCommand::Clone { repo, path, ssh } => {
                let repo = RepoInfo::get_current(host_name, Some(&repo), None, &keys)?;
                let api = keys.get_api(repo.host_url()).await?;
                let name = repo.name().unwrap();
                let url_host = crate::host_with_port(&repo.host_url());
                let ssh = ssh
                    .unwrap_or(Some(keys.default_ssh.contains(url_host)))
                    .unwrap_or(true);
                cmd_clone_repo(&api, name, path, ssh).await?;
            }
            RepoCommand::Star { repo } => {
                let repo = RepoInfo::get_current(host_name, Some(&repo), None, &keys)?;
                let api = keys.get_api(repo.host_url()).await?;
                let name = repo.name().unwrap();
                api.user_current_put_star(name.owner(), name.name()).await?;
                println!("Starred {}/{}!", name.owner(), name.name());
            }
            RepoCommand::Unstar { repo } => {
                let repo = RepoInfo::get_current(host_name, Some(&repo), None, &keys)?;
                let api = keys.get_api(repo.host_url()).await?;
                let name = repo.name().unwrap();
                api.user_current_delete_star(name.owner(), name.name())
                    .await?;
                println!("Removed star from {}/{}", name.owner(), name.name());
            }
            RepoCommand::Delete { repo } => {
                let repo = RepoInfo::get_current(host_name, Some(&repo), None, &keys)?;
                let api = keys.get_api(repo.host_url()).await?;
                let name = repo.name().unwrap();
                delete_repo(&api, name).await?;
            }
            RepoCommand::Browse { name, remote } => {
                let repo =
                    RepoInfo::get_current(host_name, name.as_ref(), remote.as_deref(), &keys)?;
                let mut url = repo.host_url().clone();
                let repo = repo
                    .name()
                    .ok_or_eyre("couldn't get repo name, please specify")?;
                url.path_segments_mut()
                    .map_err(|_| eyre!("url invalid"))?
                    .extend([repo.owner(), repo.name()]);

                open::that_detached(url.as_str()).wrap_err("Failed to open URL")?;
            }
        };
        Ok(())
    }
}

pub async fn create_repo(
    api: &Forgejo,
    org: Option<String>,
    repo: String,
    description: Option<String>,
    private: bool,
    remote: Option<String>,
    push: bool,
    ssh: bool,
) -> eyre::Result<()> {
    if remote.is_some() || push {
        let repo = git2::Repository::discover(".")?;

        let upstream = remote.as_deref().unwrap_or("origin");
        if repo.find_remote(upstream).is_ok() {
            eyre::bail!("A remote named \"{upstream}\" already exists");
        }
    }
    let repo_spec = CreateRepoOption {
        auto_init: Some(false),
        default_branch: Some("main".into()),
        description,
        gitignores: None,
        issue_labels: None,
        license: None,
        name: repo,
        object_format_name: None,
        private: Some(private),
        readme: Some(String::new()),
        template: Some(false),
        trust_model: Some(forgejo_api::structs::CreateRepoOptionTrustModel::Default),
    };
    let new_repo = if let Some(org) = org {
        api.create_org_repo(&org, repo_spec).await?
    } else {
        api.create_current_user_repo(repo_spec).await?
    };
    let html_url = new_repo
        .html_url
        .as_ref()
        .ok_or_else(|| eyre::eyre!("new_repo does not have html_url"))?;
    println!("created new repo at {}", html_url);

    if remote.is_some() || push {
        let repo = git2::Repository::discover(".")?;

        let upstream = remote.as_deref().unwrap_or("origin");
        let remote_url = git_url(&new_repo, ssh)?;
        let mut remote = repo.remote(upstream, remote_url.as_str())?;

        if push {
            let head = repo.head()?;
            if !head.is_branch() {
                eyre::bail!("HEAD is not on a branch; cannot push to remote");
            }
            let branch_shorthand = head
                .shorthand()
                .ok_or_else(|| eyre!("branch name invalid utf-8"))?
                .to_owned();
            let branch_name = std::str::from_utf8(head.name_bytes())?.to_owned();

            let auth = auth_git2::GitAuthenticator::new();
            auth.push(&repo, &mut remote, &[&branch_name])?;

            remote.fetch(&[&branch_shorthand], None, None)?;

            let mut current_branch = git2::Branch::wrap(head);
            current_branch.set_upstream(Some(&format!("{upstream}/{branch_shorthand}")))?;
        }
    }

    Ok(())
}

async fn fork_repo(api: &Forgejo, repo: &RepoName, name: Option<String>) -> eyre::Result<()> {
    let opt = forgejo_api::structs::CreateForkOption {
        name,
        organization: None,
    };
    let new_fork = api.create_fork(repo.owner(), repo.name(), opt).await?;
    let fork_full_name = new_fork
        .full_name
        .as_deref()
        .ok_or_eyre("fork does not have name")?;
    println!(
        "Forked {}/{} into {}",
        repo.owner(),
        repo.name(),
        fork_full_name
    );

    Ok(())
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum, Default)]
pub enum MigrateService {
    #[default]
    Git,
    Github,
    Gitlab,
    Forgejo,
    Gitea,
    Gogs,
    Onedev,
    Gitbucket,
    Codebase,
}

impl FromStr for MigrateService {
    type Err = MigrateServiceParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "git" => Ok(Self::Git),
            "github" => Ok(Self::Github),
            "gitlab" => Ok(Self::Gitlab),
            "forgejo" => Ok(Self::Forgejo),
            "gitea" => Ok(Self::Gitea),
            "gogs" => Ok(Self::Gogs),
            "onedev" | "one-dev" => Ok(Self::Onedev),
            "gitbucket" | "git-bucket" => Ok(Self::Gitbucket),
            "codebase" => Ok(Self::Codebase),
            _ => Err(MigrateServiceParseError),
        }
    }
}

impl MigrateService {
    fn to_api_type(self) -> forgejo_api::structs::MigrateRepoOptionsService {
        use forgejo_api::structs::MigrateRepoOptionsService as Api;
        use MigrateService as Cli;
        match self {
            Cli::Git => Api::Git,
            Cli::Github => Api::Github,
            Cli::Gitlab => Api::Gitlab,
            Cli::Forgejo => Api::Gitea,
            Cli::Gitea => Api::Gitea,
            Cli::Gogs => Api::Gogs,
            Cli::Onedev => Api::Onedev,
            Cli::Gitbucket => Api::Gitbucket,
            Cli::Codebase => Api::Codebase,
        }
    }
}

#[derive(Clone, Debug)]
pub struct MigrateServiceParseError;

impl std::error::Error for MigrateServiceParseError {}

impl std::fmt::Display for MigrateServiceParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("unknown service")
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub struct MigrateInclude {
    lfs: bool,
    wiki: bool,
    issues: bool,
    prs: bool,
    milestones: bool,
    labels: bool,
    releases: bool,
}

impl MigrateInclude {
    /// if the selection includes anything other than LFS (which is supported by base git)
    fn non_base_git(self) -> bool {
        self.wiki | self.issues | self.prs | self.milestones | self.labels | self.releases
    }
}

impl FromStr for MigrateInclude {
    type Err = MigrateIncludeParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if s == "all" {
            Ok(Self {
                lfs: true,
                wiki: true,
                issues: true,
                prs: true,
                milestones: true,
                labels: true,
                releases: true,
            })
        } else {
            let mut out = Self::default();
            for opt in s.split(",") {
                match opt {
                    "lfs" => out.lfs = true,
                    "wiki" => out.wiki = true,
                    "issues" => out.issues = true,
                    "prs" => out.prs = true,
                    "milestones" => out.milestones = true,
                    "labels" => out.labels = true,
                    "releases" => out.releases = true,
                    _ => return Err(MigrateIncludeParseError),
                }
            }
            Ok(out)
        }
    }
}

#[derive(Clone, Debug)]
pub struct MigrateIncludeParseError;

impl std::error::Error for MigrateIncludeParseError {}

impl std::fmt::Display for MigrateIncludeParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("unknown include option")
    }
}

async fn migrate_repo(
    api: &Forgejo,
    mut repo: String,
    name: String,
    mirror: bool,
    private: bool,
    include: Option<MigrateInclude>,
    lfs_endpoint: Option<url::Url>,
    service: Option<MigrateService>,
    token: bool,
    login: bool,
) -> eyre::Result<()> {
    let include = include.unwrap_or_default();
    let service = service.unwrap_or_default();

    if service == MigrateService::Git && include.non_base_git() {
        eyre::bail!("Migrating from a `git` service doesn't support migration items other than LFS. Please specify a different service or remove the included items");
    }

    if repo.ends_with("/") {
        let _ = repo.pop();
    }
    if !repo.ends_with(".git") {
        repo.push_str(".git");
    }
    let clone_url =
        url::Url::parse(&repo).or_else(|_| url::Url::parse(&format!("https://{repo}")))?;

    let (username, password) = if login {
        let username = crate::readline("Username: ").await?.trim().to_owned();
        let password = crate::readline("Password: ").await?.trim().to_owned();
        (Some(username), Some(password))
    } else {
        (None, None)
    };

    let auth_token = if token {
        let auth_token = crate::readline("Token: ").await?.trim().to_owned();
        Some(auth_token.trim().to_owned())
    } else {
        None
    };

    let migrate_options = forgejo_api::structs::MigrateRepoOptions {
        auth_password: password,
        auth_username: username,
        auth_token,
        clone_addr: clone_url.as_str().to_owned(),
        description: None,
        issues: Some(include.issues),
        labels: Some(include.labels),
        lfs: Some(include.lfs),
        lfs_endpoint: lfs_endpoint.map(|url| url.to_string()),
        milestones: Some(include.milestones),
        mirror: Some(mirror),
        mirror_interval: None,
        private: Some(private),
        pull_requests: Some(include.prs),
        releases: Some(include.releases),
        repo_name: name,
        repo_owner: None,
        service: Some(service.to_api_type()),
        uid: None,
        wiki: Some(include.wiki),
    };

    println!("Migrating...");
    let new_repo = api.repo_migrate(migrate_options).await?;
    let new_repo_url = new_repo
        .html_url
        .as_ref()
        .ok_or_eyre("new repo doesnt have url")?;
    println!("Done! View online at {new_repo_url}");

    Ok(())
}

async fn view_repo(api: &Forgejo, repo: &RepoName) -> eyre::Result<()> {
    let repo = api.repo_get(repo.owner(), repo.name()).await?;

    let SpecialRender {
        dash,
        body_prefix,
        dark_grey,
        reset,
        ..
    } = crate::special_render();

    println!("{}", repo.full_name.ok_or_eyre("no full name")?);

    if let Some(parent) = &repo.parent {
        println!(
            "Fork of {}",
            parent.full_name.as_ref().ok_or_eyre("no full name")?
        );
    }
    if repo.mirror == Some(true) {
        if let Some(original) = &repo.original_url {
            println!("Mirror of {original}")
        }
    }
    let desc = repo.description.as_deref().unwrap_or_default();
    // Don't use body::markdown, this is plain text.
    if !desc.is_empty() {
        if desc.lines().count() > 1 {
            println!();
        }
        for line in desc.lines() {
            println!("{dark_grey}{body_prefix}{reset} {line}");
        }
    }
    println!();

    let lang = repo.language.as_deref().unwrap_or_default();
    if !lang.is_empty() {
        println!("Primary language is {lang}");
    }

    let stars = repo.stars_count.unwrap_or_default();
    if stars == 1 {
        print!("{stars} star {dash} ");
    } else {
        print!("{stars} stars {dash} ");
    }

    let watchers = repo.watchers_count.unwrap_or_default();
    print!("{watchers} watching {dash} ");

    let forks = repo.forks_count.unwrap_or_default();
    if forks == 1 {
        print!("{forks} fork");
    } else {
        print!("{forks} forks");
    }
    println!();

    let mut first = true;
    if repo.has_issues.unwrap_or_default() && repo.external_tracker.is_none() {
        let issues = repo.open_issues_count.unwrap_or_default();
        if issues == 1 {
            print!("{issues} issue");
        } else {
            print!("{issues} issues");
        }
        first = false;
    }
    if repo.has_pull_requests.unwrap_or_default() {
        if !first {
            print!(" {dash} ");
        }
        let pulls = repo.open_pr_counter.unwrap_or_default();
        if pulls == 1 {
            print!("{pulls} PR");
        } else {
            print!("{pulls} PRs");
        }
        first = false;
    }
    if repo.has_releases.unwrap_or_default() {
        if !first {
            print!(" {dash} ");
        }
        let releases = repo.release_counter.unwrap_or_default();
        if releases == 1 {
            print!("{releases} release");
        } else {
            print!("{releases} releases");
        }
        first = false;
    }
    if !first {
        println!();
    }
    if let Some(external_tracker) = &repo.external_tracker {
        if let Some(tracker_url) = &external_tracker.external_tracker_url {
            println!("Issue tracker is at {tracker_url}");
        }
    }

    if let Some(html_url) = &repo.html_url {
        println!();
        println!("View online at {html_url}");
    }

    Ok(())
}

async fn view_repo_readme(api: &Forgejo, repo: &RepoName) -> eyre::Result<()> {
    let query = forgejo_api::structs::RepoGetRawFileQuery { r#ref: None };
    let file = api
        .repo_get_raw_file(repo.owner(), repo.name(), "README.md", query)
        .await;
    if let Ok(readme) = file {
        let readme_str = String::from_utf8_lossy(&readme);
        println!("{}", crate::markdown(&readme_str));
        return Ok(());
    } else {
        let query = forgejo_api::structs::RepoGetRawFileQuery { r#ref: None };
        let file = api
            .repo_get_raw_file(repo.owner(), repo.name(), "README.txt", query)
            .await;
        if let Ok(readme) = file {
            let readme_str = String::from_utf8_lossy(&readme);
            println!("{}", crate::render_text(&readme_str));
            return Ok(());
        }
    }
    eyre::bail!("Repo does not have README.md or README.txt");
}

async fn cmd_clone_repo(
    api: &Forgejo,
    name: &RepoName,
    path: Option<std::path::PathBuf>,
    ssh: bool,
) -> eyre::Result<()> {
    let repo_data = api.repo_get(name.owner(), name.name()).await?;
    let clone_url = git_url(&repo_data, ssh)?;

    let repo_name = repo_data
        .name
        .as_deref()
        .ok_or_eyre("repo does not have name")?;
    let repo_full_name = repo_data
        .full_name
        .as_deref()
        .ok_or_eyre("repo does not have full name")?;

    let path = path.unwrap_or_else(|| PathBuf::from(format!("./{repo_name}")));

    let local_repo = clone_repo(repo_full_name, clone_url, &path)?;

    if let Some(parent) = repo_data.parent.as_deref() {
        local_repo.remote("upstream", git_url(&parent, ssh)?.as_str())?;
    }

    Ok(())
}

pub fn git_url(repo: &forgejo_api::structs::Repository, ssh: bool) -> eyre::Result<&Url> {
    if ssh {
        repo.ssh_url
            .as_ref()
            .ok_or_eyre("repo does not have ssh url")
    } else {
        repo.clone_url
            .as_ref()
            .ok_or_eyre("repo does not have clone url")
    }
}

pub fn clone_repo(
    repo_name: &str,
    url: &url::Url,
    path: &std::path::Path,
) -> eyre::Result<git2::Repository> {
    let SpecialRender {
        fancy,
        hide_cursor,
        show_cursor,
        clear_line,
        ..
    } = *crate::special_render();

    let mut auth = auth_git2::GitAuthenticator::new();
    // I find it surprising that auth_git2 just hardcodes what key files to look for instead of
    // looking in .ssh/config
    if url.scheme() == "ssh" {
        let ssh_config =
            ssh2_config::SshConfig::parse_default_file(ParseRule::ALLOW_UNKNOWN_FIELDS)?;
        let params = ssh_config.query(url.host_str().ok_or_eyre("url does not have host")?);
        if let Some(identity_file) = params.identity_file.as_deref() {
            for path in identity_file {
                auth = auth.add_ssh_key_from_file(path, None);
            }
        }
    }

    let git_config = git2::Config::open_default()?;

    let mut options = git2::FetchOptions::new();
    let mut callbacks = git2::RemoteCallbacks::new();
    callbacks.credentials(auth.credentials(&git_config));

    if fancy {
        print!("{hide_cursor}");
        print!("   Preparing...");
        let _ = std::io::stdout().flush();

        callbacks.transfer_progress(|progress| {
            print!("{clear_line}\r");
            if progress.received_objects() == progress.total_objects() {
                if progress.indexed_deltas() == progress.total_deltas() {
                    print!("Finishing up...");
                } else {
                    let percent = 100.0 * (progress.indexed_deltas() as f64)
                        / (progress.total_deltas() as f64);
                    print!("   Resolving... {percent:.01}%");
                }
            } else {
                let bytes = progress.received_bytes();
                let percent = 100.0 * (progress.received_objects() as f64)
                    / (progress.total_objects() as f64);
                print!(" Downloading... {percent:.01}%");
                match bytes {
                    0..=1023 => print!(" ({}b)", bytes),
                    1024..=1048575 => print!(" ({:.01}kb)", (bytes as f64) / 1024.0),
                    1048576..=1073741823 => {
                        print!(" ({:.01}mb)", (bytes as f64) / 1048576.0)
                    }
                    1073741824.. => {
                        print!(" ({:.01}gb)", (bytes as f64) / 1073741824.0)
                    }
                }
            }
            let _ = std::io::stdout().flush();
            true
        });
    }
    options.remote_callbacks(callbacks);

    let local_repo = git2::build::RepoBuilder::new()
        .fetch_options(options)
        .clone(url.as_str(), path)?;
    if fancy {
        print!("{clear_line}{show_cursor}\r");
    }
    println!("Cloned {} into {}", repo_name, path.display());
    Ok(local_repo)
}

async fn delete_repo(api: &Forgejo, name: &RepoName) -> eyre::Result<()> {
    print!(
        "Are you sure you want to delete {}/{}? (y/N) ",
        name.owner(),
        name.name()
    );
    let user_response = crate::readline("").await?;
    let yes = matches!(user_response.trim(), "y" | "Y" | "yes" | "Yes");
    if yes {
        api.repo_delete(name.owner(), name.name()).await?;
        println!("Deleted {}/{}", name.owner(), name.name());
    } else {
        println!("Did not delete");
    }
    Ok(())
}
0707010000001A000081A4000000000000000000000001681904CF00008804000000000000000000000000000000000000001E00000000forgejo-cli-0.3.0/src/user.rsuse clap::{Args, Subcommand};
use eyre::{Context, OptionExt};
use forgejo_api::Forgejo;

use crate::{repo::RepoInfo, SpecialRender};

#[derive(Args, Clone, Debug)]
pub struct UserCommand {
    /// The local git remote that points to the repo to operate on.
    #[clap(long, short = 'R')]
    remote: Option<String>,
    #[clap(subcommand)]
    command: UserSubcommand,
}

#[derive(Subcommand, Clone, Debug)]
pub enum UserSubcommand {
    /// Search for a user by username
    Search {
        /// The name to search for
        query: String,
        #[clap(long, short)]
        page: Option<usize>,
    },
    /// View a user's profile page
    View {
        /// The name of the user to view
        ///
        /// Omit to view your own page
        user: Option<String>,
    },
    /// Open a user's profile page in your browser
    Browse {
        /// The name of the user to open in your browser
        ///
        /// Omit to view your own page
        user: Option<String>,
    },
    /// Follow a user
    Follow {
        /// The name of the user to follow
        user: String,
    },
    /// Unfollow a user
    Unfollow {
        /// The name of the user to follow
        user: String,
    },
    /// List everyone a user's follows
    Following {
        /// The name of the user whose follows to list
        ///
        /// Omit to view your own follows
        user: Option<String>,
    },
    /// List a user's followers
    Followers {
        /// The name of the user whose followers to list
        ///
        /// Omit to view your own followers
        user: Option<String>,
    },
    /// Block a user
    Block {
        /// The name of the user to block
        user: String,
    },
    /// Unblock a user
    Unblock {
        /// The name of the user to unblock
        user: String,
    },
    /// List a user's repositories
    Repos {
        /// The name of the user whose repos to list
        ///
        /// Omit to view your own repos.
        user: Option<String>,
        /// List starred repos instead of owned repos
        #[clap(long)]
        starred: bool,
        /// Method by which to sort the list
        #[clap(long)]
        sort: Option<RepoSortOrder>,
    },
    /// List the organizations a user is a member of
    Orgs {
        /// The name of the user to view org membership of
        ///
        /// Omit to view your own orgs.
        user: Option<String>,
    },
    /// List a user's recent activity
    Activity {
        /// The name of the user to view the activity of
        ///
        /// Omit to view your own activity.
        user: Option<String>,
    },
    /// Edit your user settings
    #[clap(subcommand)]
    Edit(EditCommand),
}

#[derive(Subcommand, Clone, Debug)]
pub enum EditCommand {
    /// Set your bio
    Bio {
        /// The new description. Leave this out to open your editor.
        content: Option<String>,
    },
    /// Set your full name
    Name {
        /// The new name.
        #[clap(group = "arg")]
        name: Option<String>,
        /// Remove your name from your profile
        #[clap(long, short, group = "arg")]
        unset: bool,
    },
    /// Set your pronouns
    Pronouns {
        /// The new pronouns.
        #[clap(group = "arg")]
        pronouns: Option<String>,
        /// Remove your pronouns from your profile
        #[clap(long, short, group = "arg")]
        unset: bool,
    },
    /// Set your activity visibility
    Location {
        /// The new location.
        #[clap(group = "arg")]
        location: Option<String>,
        /// Remove your location from your profile
        #[clap(long, short, group = "arg")]
        unset: bool,
    },
    /// Set your activity visibility
    Activity {
        /// The visibility of your activity.
        #[clap(long, short)]
        visibility: VisbilitySetting,
    },
    /// Manage the email addresses associated with your account
    Email {
        /// Set the visibility of your email address.
        #[clap(long, short)]
        visibility: Option<VisbilitySetting>,
        /// Add a new email address
        #[clap(long, short)]
        add: Vec<String>,
        /// Remove an email address
        #[clap(long, short)]
        rm: Vec<String>,
    },
    /// Set your linked website
    Website {
        /// Your website URL.
        #[clap(group = "arg")]
        url: Option<String>,
        /// Remove your website from your profile
        #[clap(long, short, group = "arg")]
        unset: bool,
    },
}

#[derive(clap::ValueEnum, Clone, Debug, PartialEq, Eq)]
pub enum VisbilitySetting {
    Hidden,
    Public,
}

impl UserCommand {
    pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
        let repo = RepoInfo::get_current(host_name, None, self.remote.as_deref(), &keys)?;
        let api = keys.get_api(repo.host_url()).await?;
        match self.command {
            UserSubcommand::Search { query, page } => user_search(&api, &query, page).await?,
            UserSubcommand::View { user } => view_user(&api, user.as_deref()).await?,
            UserSubcommand::Browse { user } => {
                browse_user(&api, repo.host_url(), user.as_deref()).await?
            }
            UserSubcommand::Follow { user } => follow_user(&api, &user).await?,
            UserSubcommand::Unfollow { user } => unfollow_user(&api, &user).await?,
            UserSubcommand::Following { user } => list_following(&api, user.as_deref()).await?,
            UserSubcommand::Followers { user } => list_followers(&api, user.as_deref()).await?,
            UserSubcommand::Block { user } => block_user(&api, &user).await?,
            UserSubcommand::Unblock { user } => unblock_user(&api, &user).await?,
            UserSubcommand::Repos {
                user,
                starred,
                sort,
            } => list_repos(&api, user.as_deref(), starred, sort).await?,
            UserSubcommand::Orgs { user } => list_orgs(&api, user.as_deref()).await?,
            UserSubcommand::Activity { user } => list_activity(&api, user.as_deref()).await?,
            UserSubcommand::Edit(cmd) => match cmd {
                EditCommand::Bio { content } => edit_bio(&api, content).await?,
                EditCommand::Name { name, unset } => edit_name(&api, name, unset).await?,
                EditCommand::Pronouns { pronouns, unset } => {
                    edit_pronouns(&api, pronouns, unset).await?
                }
                EditCommand::Location { location, unset } => {
                    edit_location(&api, location, unset).await?
                }
                EditCommand::Activity { visibility } => edit_activity(&api, visibility).await?,
                EditCommand::Email {
                    visibility,
                    add,
                    rm,
                } => edit_email(&api, visibility, add, rm).await?,
                EditCommand::Website { url, unset } => edit_website(&api, url, unset).await?,
            },
        }
        Ok(())
    }
}

async fn user_search(api: &Forgejo, query: &str, page: Option<usize>) -> eyre::Result<()> {
    let page = page.unwrap_or(1);
    if page == 0 {
        println!("There is no page 0");
    }
    let query = forgejo_api::structs::UserSearchQuery {
        q: Some(query.to_owned()),
        ..Default::default()
    };
    let result = api.user_search(query).await?;
    let users = result.data.ok_or_eyre("search did not return data")?;
    let ok = result.ok.ok_or_eyre("search did not return ok")?;
    if !ok {
        println!("Search failed");
        return Ok(());
    }
    if users.is_empty() {
        println!("No users matched that query");
    } else {
        let SpecialRender {
            bullet,
            dash,
            bold,
            reset,
            ..
        } = *crate::special_render();
        let page_start = (page - 1) * 20;
        let pages_total = users.len().div_ceil(20);
        if page_start >= users.len() {
            if pages_total == 1 {
                println!("There is only 1 page");
            } else {
                println!("There are only {pages_total} pages");
            }
        } else {
            for user in users.iter().skip(page_start).take(20) {
                let username = user
                    .login
                    .as_deref()
                    .ok_or_eyre("user does not have name")?;
                println!("{bullet} {bold}{username}{reset}");
            }
            println!(
                "Showing {bold}{}{dash}{}{reset} of {bold}{}{reset} results ({page}/{pages_total})",
                page_start + 1,
                (page_start + 20).min(users.len()),
                users.len()
            );
            if users.len() > 20 {
                println!("View more with the --page flag");
            }
        }
    }
    Ok(())
}

async fn view_user(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
    let SpecialRender {
        bold,
        dash,
        bright_cyan,
        light_grey,
        reset,
        ..
    } = *crate::special_render();

    let user_data = match user {
        Some(user) => api.user_get(user).await?,
        None => api.user_get_current().await?,
    };
    let username = user_data
        .login
        .as_deref()
        .ok_or_eyre("user has no username")?;
    print!("{bright_cyan}{bold}{username}{reset}");
    if let Some(pronouns) = user_data.pronouns.as_deref() {
        if !pronouns.is_empty() {
            print!("{light_grey} {dash} {bold}{pronouns}{reset}");
        }
    }
    println!();
    let followers = user_data.followers_count.unwrap_or_default();
    let following = user_data.following_count.unwrap_or_default();
    println!("{bold}{followers}{reset} followers {dash} {bold}{following}{reset} following");
    let mut first = true;
    if let Some(website) = user_data.website.as_deref() {
        if !website.is_empty() {
            print!("{bold}{website}{reset}");
            first = false;
        }
    }
    if let Some(email) = user_data.email.as_deref() {
        if !email.is_empty() && !email.contains("noreply") {
            if !first {
                print!(" {dash} ");
            }
            print!("{bold}{email}{reset}");
        }
    }
    if !first {
        println!();
    }

    if let Some(desc) = user_data.description.as_deref() {
        if !desc.is_empty() {
            println!();
            println!("{}", crate::markdown(desc));
            println!();
        }
    }

    let joined = user_data
        .created
        .ok_or_eyre("user does not have join date")?;
    let date_format = time::macros::format_description!("[month repr:short] [day], [year]");
    println!("Joined on {bold}{}{reset}", joined.format(&date_format)?);

    Ok(())
}

async fn browse_user(api: &Forgejo, host_url: &url::Url, user: Option<&str>) -> eyre::Result<()> {
    let username = match user {
        Some(user) => user.to_owned(),
        None => {
            let myself = api.user_get_current().await?;
            myself
                .login
                .ok_or_eyre("authenticated user does not have login")?
        }
    };
    // `User` doesn't have an `html_url` field, so we gotta construct the user
    // page url ourselves
    let mut url = host_url.clone();
    url.path_segments_mut()
        .map_err(|_| eyre::eyre!("invalid host url"))?
        .push(&username);
    open::that_detached(url.as_str()).wrap_err("Failed to open URL")?;

    Ok(())
}

async fn follow_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
    api.user_current_put_follow(user).await?;
    println!("Followed {user}");
    Ok(())
}

async fn unfollow_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
    api.user_current_delete_follow(user).await?;
    println!("Unfollowed {user}");
    Ok(())
}

async fn list_following(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
    let (_, following) = match user {
        Some(user) => {
            let query = forgejo_api::structs::UserListFollowingQuery {
                limit: Some(u32::MAX),
                ..Default::default()
            };
            api.user_list_following(user, query).await?
        }
        None => {
            let query = forgejo_api::structs::UserCurrentListFollowingQuery {
                limit: Some(u32::MAX),
                ..Default::default()
            };
            api.user_current_list_following(query).await?
        }
    };

    if following.is_empty() {
        match user {
            Some(name) => println!("{name} isn't following anyone"),
            None => println!("You aren't following anyone"),
        }
    } else {
        match user {
            Some(name) => println!("{name} is following:"),
            None => println!("You are following:"),
        }
        let SpecialRender { bullet, .. } = *crate::special_render();

        for followed in following {
            let username = followed
                .login
                .as_deref()
                .ok_or_eyre("user does not have username")?;
            println!("{bullet} {username}");
        }
    }

    Ok(())
}

async fn list_followers(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
    let (_, followers) = match user {
        Some(user) => {
            let query = forgejo_api::structs::UserListFollowersQuery {
                limit: Some(u32::MAX),
                ..Default::default()
            };
            api.user_list_followers(user, query).await?
        }
        None => {
            let query = forgejo_api::structs::UserCurrentListFollowersQuery {
                limit: Some(u32::MAX),
                ..Default::default()
            };
            api.user_current_list_followers(query).await?
        }
    };

    if followers.is_empty() {
        match user {
            Some(name) => println!("{name} has no followers"),
            None => println!("You have no followers :("),
        }
    } else {
        match user {
            Some(name) => println!("{name} is followed by:"),
            None => println!("You are followed by:"),
        }
        let SpecialRender { bullet, .. } = *crate::special_render();

        for follower in followers {
            let username = follower
                .login
                .as_deref()
                .ok_or_eyre("user does not have username")?;
            println!("{bullet} {username}");
        }
    }

    Ok(())
}

async fn block_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
    api.user_block_user(user).await?;
    println!("Blocked {user}");
    Ok(())
}

async fn unblock_user(api: &Forgejo, user: &str) -> eyre::Result<()> {
    api.user_unblock_user(user).await?;
    println!("Unblocked {user}");
    Ok(())
}

#[derive(clap::ValueEnum, Clone, Debug, Default)]
pub enum RepoSortOrder {
    #[default]
    Name,
    Modified,
    Created,
    Stars,
    Forks,
}

async fn list_repos(
    api: &Forgejo,
    user: Option<&str>,
    starred: bool,
    sort: Option<RepoSortOrder>,
) -> eyre::Result<()> {
    let (_, mut repos) = if starred {
        match user {
            Some(user) => {
                let query = forgejo_api::structs::UserListStarredQuery {
                    limit: Some(u32::MAX),
                    ..Default::default()
                };
                api.user_list_starred(user, query).await?
            }
            None => {
                let query = forgejo_api::structs::UserCurrentListStarredQuery {
                    limit: Some(u32::MAX),
                    ..Default::default()
                };
                api.user_current_list_starred(query).await?
            }
        }
    } else {
        match user {
            Some(user) => {
                let query = forgejo_api::structs::UserListReposQuery {
                    limit: Some(u32::MAX),
                    ..Default::default()
                };
                api.user_list_repos(user, query).await?
            }
            None => {
                let query = forgejo_api::structs::UserCurrentListReposQuery {
                    limit: Some(u32::MAX),
                    ..Default::default()
                };
                api.user_current_list_repos(query).await?
            }
        }
    };

    if repos.is_empty() {
        if starred {
            match user {
                Some(user) => println!("{user} has not starred any repos"),
                None => println!("You have not starred any repos"),
            }
        } else {
            match user {
                Some(user) => println!("{user} does not own any repos"),
                None => println!("You do not own any repos"),
            }
        };
    } else {
        let sort_fn: fn(
            &forgejo_api::structs::Repository,
            &forgejo_api::structs::Repository,
        ) -> std::cmp::Ordering = match sort.unwrap_or_default() {
            RepoSortOrder::Name => |a, b| a.full_name.cmp(&b.full_name),
            RepoSortOrder::Modified => |a, b| b.updated_at.cmp(&a.updated_at),
            RepoSortOrder::Created => |a, b| b.created_at.cmp(&a.created_at),
            RepoSortOrder::Stars => |a, b| b.stars_count.cmp(&a.stars_count),
            RepoSortOrder::Forks => |a, b| b.forks_count.cmp(&a.forks_count),
        };
        repos.sort_unstable_by(sort_fn);

        let SpecialRender { bullet, .. } = *crate::special_render();
        for repo in &repos {
            let name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have name")?;
            println!("{bullet} {name}");
        }
        if repos.len() == 1 {
            println!("1 repo");
        } else {
            println!("{} repos", repos.len());
        }
    }

    Ok(())
}

async fn list_orgs(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
    let (_, mut orgs) = match user {
        Some(user) => {
            let query = forgejo_api::structs::OrgListUserOrgsQuery {
                limit: Some(u32::MAX),
                ..Default::default()
            };
            api.org_list_user_orgs(user, query).await?
        }
        None => {
            let query = forgejo_api::structs::OrgListCurrentUserOrgsQuery {
                limit: Some(u32::MAX),
                ..Default::default()
            };
            api.org_list_current_user_orgs(query).await?
        }
    };

    if orgs.is_empty() {
        match user {
            Some(user) => println!("{user} is not a member of any organizations"),
            None => println!("You are not a member of any organizations"),
        }
    } else {
        orgs.sort_unstable_by(|a, b| a.name.cmp(&b.name));

        let SpecialRender { bullet, dash, .. } = *crate::special_render();
        for org in &orgs {
            let name = org.name.as_deref().ok_or_eyre("org does not have name")?;
            let full_name = org
                .full_name
                .as_deref()
                .ok_or_eyre("org does not have name")?;
            if !full_name.is_empty() {
                println!("{bullet} {name} {dash} \"{full_name}\"");
            } else {
                println!("{bullet} {name}");
            }
        }
        if orgs.len() == 1 {
            println!("1 organization");
        } else {
            println!("{} organizations", orgs.len());
        }
    }
    Ok(())
}

async fn list_activity(api: &Forgejo, user: Option<&str>) -> eyre::Result<()> {
    let user = match user {
        Some(s) => s.to_owned(),
        None => {
            let myself = api.user_get_current().await?;
            myself.login.ok_or_eyre("current user does not have name")?
        }
    };
    let query = forgejo_api::structs::UserListActivityFeedsQuery {
        only_performed_by: Some(true),
        ..Default::default()
    };
    let (_, feed) = api.user_list_activity_feeds(&user, query).await?;

    for activity in feed {
        print_activity(&activity)?;
    }
    Ok(())
}

pub fn print_activity(activity: &forgejo_api::structs::Activity) -> eyre::Result<()> {
    let SpecialRender {
        bold,
        yellow,
        bright_cyan,
        reset,
        ..
    } = *crate::special_render();
    let actor = activity
        .act_user
        .as_ref()
        .ok_or_eyre("activity does not have actor")?;
    let actor_name = actor
        .login
        .as_deref()
        .ok_or_eyre("actor does not have name")?;
    let op_type = activity
        .op_type
        .as_ref()
        .ok_or_eyre("activity does not have op type")?;

    // do not add ? to these. they are here to make each branch smaller
    let repo = activity
        .repo
        .as_ref()
        .ok_or_eyre("activity does not have repo");
    let content = activity
        .content
        .as_deref()
        .ok_or_eyre("activity does not have content");
    let ref_name = activity
        .ref_name
        .as_deref()
        .ok_or_eyre("repo does not have full name");

    fn issue_name<'a, 'b>(
        repo: &'a forgejo_api::structs::Repository,
        content: &'b str,
    ) -> eyre::Result<(&'a str, &'b str)> {
        let full_name = repo
            .full_name
            .as_deref()
            .ok_or_eyre("repo does not have full name")?;
        let (issue_id, _issue_name) = content.split_once("|").unwrap_or((content, ""));
        Ok((full_name, issue_id))
    }

    print!("");
    use forgejo_api::structs::ActivityOpType;
    match op_type {
        ActivityOpType::CreateRepo => {
            let repo = repo?;
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            if let Some(parent) = &repo.parent {
                let parent_full_name = parent
                    .full_name
                    .as_deref()
                    .ok_or_eyre("parent repo does not have full name")?;
                println!("{bold}{actor_name}{reset} forked repository {bold}{yellow}{parent_full_name}{reset} to {bold}{yellow}{full_name}{reset}");
            } else if repo.mirror.is_some_and(|b| b) {
                println!(
                    "{bold}{actor_name}{reset} created mirror {bold}{yellow}{full_name}{reset}"
                );
            } else {
                println!(
                    "{bold}{actor_name}{reset} created repository {bold}{yellow}{full_name}{reset}"
                );
            }
        }
        ActivityOpType::RenameRepo => {
            let repo = repo?;
            let content = content?;
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            println!("{bold}{actor_name}{reset} renamed repository from {bold}{yellow}\"{content}\"{reset} to {bold}{yellow}{full_name}{reset}");
        }
        ActivityOpType::StarRepo => {
            let repo = repo?;
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            println!(
                "{bold}{actor_name}{reset} starred repository {bold}{yellow}{full_name}{reset}"
            );
        }
        ActivityOpType::WatchRepo => {
            let repo = repo?;
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            println!(
                "{bold}{actor_name}{reset} watched repository {bold}{yellow}{full_name}{reset}"
            );
        }
        ActivityOpType::CommitRepo => {
            let repo = repo?;
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            let ref_name = ref_name?;
            let branch = ref_name
                .rsplit_once("/")
                .map(|(_, b)| b)
                .unwrap_or(ref_name);
            if !content?.is_empty() {
                println!("{bold}{actor_name}{reset} pushed to {bold}{bright_cyan}{branch}{reset} on {bold}{yellow}{full_name}{reset}");
            }
        }
        ActivityOpType::CreateIssue => {
            let (name, id) = issue_name(repo?, content?)?;
            println!("{bold}{actor_name}{reset} opened issue {bold}{yellow}{name}#{id}{reset}");
        }
        ActivityOpType::CreatePullRequest => {
            let (name, id) = issue_name(repo?, content?)?;
            println!(
                "{bold}{actor_name}{reset} created pull request {bold}{yellow}{name}#{id}{reset}"
            );
        }
        ActivityOpType::TransferRepo => {
            let repo = repo?;
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            let content = content?;
            println!("{bold}{actor_name}{reset} transfered repository {bold}{yellow}{content}{reset} to {bold}{yellow}{full_name}{reset}");
        }
        ActivityOpType::PushTag => {
            let repo = repo?;
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            let ref_name = ref_name?;
            let tag = ref_name
                .rsplit_once("/")
                .map(|(_, b)| b)
                .unwrap_or(ref_name);
            println!("{bold}{actor_name}{reset} pushed tag {bold}{bright_cyan}{tag}{reset} to {bold}{yellow}{full_name}{reset}");
        }
        ActivityOpType::CommentIssue => {
            let (name, id) = issue_name(repo?, content?)?;
            println!(
                "{bold}{actor_name}{reset} commented on issue {bold}{yellow}{name}#{id}{reset}"
            );
        }
        ActivityOpType::MergePullRequest | ActivityOpType::AutoMergePullRequest => {
            let (name, id) = issue_name(repo?, content?)?;
            println!(
                "{bold}{actor_name}{reset} merged pull request {bold}{yellow}{name}#{id}{reset}"
            );
        }
        ActivityOpType::CloseIssue => {
            let (name, id) = issue_name(repo?, content?)?;
            println!("{bold}{actor_name}{reset} closed issue {bold}{yellow}{name}#{id}{reset}");
        }
        ActivityOpType::ReopenIssue => {
            let (name, id) = issue_name(repo?, content?)?;
            println!("{bold}{actor_name}{reset} reopened issue {bold}{yellow}{name}#{id}{reset}");
        }
        ActivityOpType::ClosePullRequest => {
            let (name, id) = issue_name(repo?, content?)?;
            println!(
                "{bold}{actor_name}{reset} closed pull request {bold}{yellow}{name}#{id}{reset}"
            );
        }
        ActivityOpType::ReopenPullRequest => {
            let (name, id) = issue_name(repo?, content?)?;
            println!(
                "{bold}{actor_name}{reset} reopened pull request {bold}{yellow}{name}#{id}{reset}"
            );
        }
        ActivityOpType::DeleteTag => {
            let repo = repo?;
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            let ref_name = ref_name?;
            let tag = ref_name
                .rsplit_once("/")
                .map(|(_, b)| b)
                .unwrap_or(ref_name);
            println!("{bold}{actor_name}{reset} deleted tag {bold}{bright_cyan}{tag}{reset} from {bold}{yellow}{full_name}{reset}");
        }
        ActivityOpType::DeleteBranch => {
            let repo = repo?;
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            let ref_name = ref_name?;
            let branch = ref_name
                .rsplit_once("/")
                .map(|(_, b)| b)
                .unwrap_or(ref_name);
            println!("{bold}{actor_name}{reset} deleted branch {bold}{bright_cyan}{branch}{reset} from {bold}{yellow}{full_name}{reset}");
        }
        ActivityOpType::MirrorSyncPush => {}
        ActivityOpType::MirrorSyncCreate => {}
        ActivityOpType::MirrorSyncDelete => {}
        ActivityOpType::ApprovePullRequest => {
            let (name, id) = issue_name(repo?, content?)?;
            println!("{bold}{actor_name}{reset} approved {bold}{yellow}{name}#{id}{reset}");
        }
        ActivityOpType::RejectPullRequest => {
            let (name, id) = issue_name(repo?, content?)?;
            println!(
                "{bold}{actor_name}{reset} suggested changes for {bold}{yellow}{name}#{id}{reset}"
            );
        }
        ActivityOpType::CommentPull => {
            let (name, id) = issue_name(repo?, content?)?;
            println!("{bold}{actor_name}{reset} commented on pull request {bold}{yellow}{name}#{id}{reset}");
        }
        ActivityOpType::PublishRelease => {
            let repo = repo?;
            let full_name = repo
                .full_name
                .as_deref()
                .ok_or_eyre("repo does not have full name")?;
            let content = content?;
            println!("{bold}{actor_name}{reset} created release {bold}{bright_cyan}\"{content}\"{reset} to {bold}{yellow}{full_name}{reset}");
        }
        ActivityOpType::PullReviewDismissed => {}
        ActivityOpType::PullRequestReadyForReview => {}
    }

    Ok(())
}

fn default_settings_opt() -> forgejo_api::structs::UserSettingsOptions {
    forgejo_api::structs::UserSettingsOptions {
        description: None,
        diff_view_style: None,
        enable_repo_unit_hints: None,
        full_name: None,
        hide_activity: None,
        hide_email: None,
        language: None,
        location: None,
        pronouns: None,
        theme: None,
        website: None,
    }
}

async fn edit_bio(api: &Forgejo, new_bio: Option<String>) -> eyre::Result<()> {
    let new_bio = match new_bio {
        Some(s) => s,
        None => {
            let mut bio = api
                .user_get_current()
                .await?
                .description
                .unwrap_or_default();
            crate::editor(&mut bio, Some("md")).await?;
            bio
        }
    };
    let opt = forgejo_api::structs::UserSettingsOptions {
        description: Some(new_bio),
        ..default_settings_opt()
    };
    api.update_user_settings(opt).await?;
    Ok(())
}

async fn edit_name(api: &Forgejo, new_name: Option<String>, unset: bool) -> eyre::Result<()> {
    match (new_name, unset) {
        (Some(_), true) => unreachable!(),
        (Some(name), false) if !name.is_empty() => {
            let opt = forgejo_api::structs::UserSettingsOptions {
                full_name: Some(name),
                ..default_settings_opt()
            };
            api.update_user_settings(opt).await?;
        }
        (None, true) => {
            let opt = forgejo_api::structs::UserSettingsOptions {
                full_name: Some(String::new()),
                ..default_settings_opt()
            };
            api.update_user_settings(opt).await?;
        }
        _ => println!("Use --unset to remove your name from your profile"),
    }
    Ok(())
}

async fn edit_pronouns(
    api: &Forgejo,
    new_pronouns: Option<String>,
    unset: bool,
) -> eyre::Result<()> {
    match (new_pronouns, unset) {
        (Some(_), true) => unreachable!(),
        (Some(pronouns), false) if !pronouns.is_empty() => {
            let opt = forgejo_api::structs::UserSettingsOptions {
                pronouns: Some(pronouns),
                ..default_settings_opt()
            };
            api.update_user_settings(opt).await?;
        }
        (None, true) => {
            let opt = forgejo_api::structs::UserSettingsOptions {
                pronouns: Some(String::new()),
                ..default_settings_opt()
            };
            api.update_user_settings(opt).await?;
        }
        _ => println!("Use --unset to remove your pronouns from your profile"),
    }
    Ok(())
}

async fn edit_location(
    api: &Forgejo,
    new_location: Option<String>,
    unset: bool,
) -> eyre::Result<()> {
    match (new_location, unset) {
        (Some(_), true) => unreachable!(),
        (Some(location), false) if !location.is_empty() => {
            let opt = forgejo_api::structs::UserSettingsOptions {
                location: Some(location),
                ..default_settings_opt()
            };
            api.update_user_settings(opt).await?;
        }
        (None, true) => {
            let opt = forgejo_api::structs::UserSettingsOptions {
                location: Some(String::new()),
                ..default_settings_opt()
            };
            api.update_user_settings(opt).await?;
        }
        _ => println!("Use --unset to remove your location from your profile"),
    }
    Ok(())
}

async fn edit_activity(api: &Forgejo, visibility: VisbilitySetting) -> eyre::Result<()> {
    let opt = forgejo_api::structs::UserSettingsOptions {
        hide_activity: Some(visibility == VisbilitySetting::Hidden),
        ..default_settings_opt()
    };
    api.update_user_settings(opt).await?;
    Ok(())
}

async fn edit_email(
    api: &Forgejo,
    visibility: Option<VisbilitySetting>,
    add: Vec<String>,
    rm: Vec<String>,
) -> eyre::Result<()> {
    if let Some(vis) = visibility {
        let opt = forgejo_api::structs::UserSettingsOptions {
            hide_activity: Some(vis == VisbilitySetting::Hidden),
            ..default_settings_opt()
        };
        api.update_user_settings(opt).await?;
    }
    if !add.is_empty() {
        let opt = forgejo_api::structs::CreateEmailOption { emails: Some(add) };
        api.user_add_email(opt).await?;
    }
    if !rm.is_empty() {
        let opt = forgejo_api::structs::DeleteEmailOption { emails: Some(rm) };
        api.user_delete_email(opt).await?;
    }
    Ok(())
}

async fn edit_website(api: &Forgejo, new_url: Option<String>, unset: bool) -> eyre::Result<()> {
    match (new_url, unset) {
        (Some(_), true) => unreachable!(),
        (Some(url), false) if !url.is_empty() => {
            let opt = forgejo_api::structs::UserSettingsOptions {
                website: Some(url),
                ..default_settings_opt()
            };
            api.update_user_settings(opt).await?;
        }
        (None, true) => {
            let opt = forgejo_api::structs::UserSettingsOptions {
                website: Some(String::new()),
                ..default_settings_opt()
            };
            api.update_user_settings(opt).await?;
        }
        _ => println!("Use --unset to remove your name from your profile"),
    }
    Ok(())
}
0707010000001B000081A4000000000000000000000001681904CF000009B2000000000000000000000000000000000000002100000000forgejo-cli-0.3.0/src/version.rsuse clap::Args;
#[cfg(feature = "update-check")]
use eyre::OptionExt;

#[derive(Args, Clone, Debug)]
pub struct VersionCommand {
    /// Checks for updates
    #[clap(long)]
    #[cfg(feature = "update-check")]
    check: bool,
    #[clap(short, long)]
    verbose: bool,
}

const BUILD_TYPE: &str = match option_env!("BUILD_TYPE") {
    Some(s) => s,
    None => "from source",
};

impl VersionCommand {
    pub async fn run(self) -> eyre::Result<()> {
        println!("{} v{}", env!("CARGO_BIN_NAME"), env!("CARGO_PKG_VERSION"));
        if self.verbose {
            println!("user agent: {}", crate::USER_AGENT);
            println!("build type: {BUILD_TYPE}");
            println!("    target: {}", env!("BUILD_TARGET"));
        }
        #[cfg(feature = "update-check")]
        self.update_msg().await?;
        Ok(())
    }

    #[cfg(feature = "update-check")]
    pub async fn update_msg(self) -> eyre::Result<()> {
        use std::cmp::Ordering;

        if self.check {
            let url = url::Url::parse("https://codeberg.org/")?;
            let api = forgejo_api::Forgejo::with_user_agent(
                forgejo_api::Auth::None,
                url,
                crate::USER_AGENT,
            )?;

            let latest = api
                .repo_get_latest_release("Cyborus", "forgejo-cli")
                .await?;
            let latest_tag = latest
                .tag_name
                .ok_or_eyre("latest release does not have name")?;
            let latest_ver = latest_tag
                .strip_prefix("v")
                .unwrap_or(&latest_tag)
                .parse::<semver::Version>()?;

            let current_ver = env!("CARGO_PKG_VERSION").parse::<semver::Version>()?;

            match current_ver.cmp(&latest_ver) {
                Ordering::Less => {
                    let latest_url = latest
                        .html_url
                        .ok_or_eyre("latest release does not have url")?;
                    println!("New version available: {latest_ver}");
                    println!("Get it at {}", latest_url);
                }
                Ordering::Equal => {
                    println!("Up to date!");
                }
                Ordering::Greater => {
                    println!("You are ahead of the latest published version");
                }
            }
        } else {
            println!("Check for a new version with `fj version --check`");
        }
        Ok(())
    }
}
0707010000001C000081A4000000000000000000000001681904CF000003C6000000000000000000000000000000000000002000000000forgejo-cli-0.3.0/src/whoami.rsuse clap::{self, Args};
use eyre::{Context, OptionExt};

use crate::{repo::RepoInfo, KeyInfo};

#[derive(Args, Clone, Debug)]
pub struct WhoAmICommand {
    #[clap(long, short)]
    remote: Option<String>,
}

impl WhoAmICommand {
    pub async fn run(self, keys: &mut KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
        let url = RepoInfo::get_current(host_name, None, self.remote.as_deref(), &keys)
            .wrap_err("could not find host, try specifying with --host")?
            .host_url()
            .clone();
        let name = keys.get_login(&url).ok_or_eyre("not logged in")?.username();
        let host = url
            .host_str()
            .ok_or_eyre("instance url does not have host")?;
        if url.path() == "/" || url.path().is_empty() {
            println!("currently signed in to {name}@{host}");
        } else {
            println!("currently signed in to {name}@{host}{}", url.path());
        };
        Ok(())
    }
}
0707010000001D000081A4000000000000000000000001681904CF000011D2000000000000000000000000000000000000001E00000000forgejo-cli-0.3.0/src/wiki.rsuse std::path::PathBuf;

use base64ct::Encoding;
use clap::{Args, Subcommand};
use eyre::{Context, OptionExt};
use forgejo_api::Forgejo;

use crate::{
    repo::{RepoArg, RepoInfo, RepoName},
    SpecialRender,
};

#[derive(Args, Clone, Debug)]
pub struct WikiCommand {
    /// The local git remote that points to the repo to operate on.
    #[clap(long, short = 'R')]
    remote: Option<String>,
    #[clap(subcommand)]
    command: WikiSubcommand,
}

#[derive(Subcommand, Clone, Debug)]
pub enum WikiSubcommand {
    Contents {
        repo: Option<RepoArg>,
    },
    View {
        #[clap(long, short)]
        repo: Option<RepoArg>,
        page: String,
    },
    Clone {
        repo: Option<RepoArg>,
        #[clap(long, short)]
        path: Option<PathBuf>,
    },
    Browse {
        #[clap(long, short)]
        repo: Option<RepoArg>,
        page: String,
    },
}

impl WikiCommand {
    pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> {
        use WikiSubcommand::*;

        let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref(), &keys)?;
        let api = keys.get_api(repo.host_url()).await?;
        let repo = repo
            .name()
            .ok_or_else(|| eyre::eyre!("couldn't guess repo"))?;

        match self.command {
            Contents { repo: _ } => wiki_contents(repo, &api).await?,
            View { repo: _, page } => view_wiki_page(repo, &api, &page).await?,
            Clone { repo: _, path } => clone_wiki(repo, &api, path).await?,
            Browse { repo: _, page } => browse_wiki_page(repo, &api, &page).await?,
        }
        Ok(())
    }

    fn repo(&self) -> Option<&RepoArg> {
        use WikiSubcommand::*;
        match &self.command {
            Contents { repo } | View { repo, .. } | Clone { repo, .. } | Browse { repo, .. } => {
                repo.as_ref()
            }
        }
    }
}

async fn wiki_contents(repo: &RepoName, api: &Forgejo) -> eyre::Result<()> {
    let SpecialRender { bullet, .. } = *crate::special_render();

    let query = forgejo_api::structs::RepoGetWikiPagesQuery {
        page: None,
        limit: None,
    };
    let (_, pages) = api
        .repo_get_wiki_pages(repo.owner(), repo.name(), query)
        .await?;
    for page in pages {
        let title = page
            .title
            .as_deref()
            .ok_or_eyre("page does not have title")?;
        println!("{bullet} {title}");
    }

    Ok(())
}

async fn view_wiki_page(repo: &RepoName, api: &Forgejo, page: &str) -> eyre::Result<()> {
    let SpecialRender { bold, reset, .. } = *crate::special_render();

    let page = api
        .repo_get_wiki_page(repo.owner(), repo.name(), page)
        .await?;

    let title = page
        .title
        .as_deref()
        .ok_or_eyre("page does not have title")?;
    println!("{bold}{title}{reset}");
    println!();

    let contents_b64 = page
        .content_base64
        .as_deref()
        .ok_or_eyre("page does not have content")?;
    let contents = String::from_utf8(base64ct::Base64::decode_vec(contents_b64)?)
        .wrap_err("page content is not utf-8")?;

    println!("{}", crate::markdown(&contents));
    Ok(())
}

async fn browse_wiki_page(repo: &RepoName, api: &Forgejo, page: &str) -> eyre::Result<()> {
    let page = api
        .repo_get_wiki_page(repo.owner(), repo.name(), page)
        .await?;
    let html_url = page
        .html_url
        .as_ref()
        .ok_or_eyre("page does not have html url")?;
    open::that_detached(html_url.as_str()).wrap_err("Failed to open URL")?;
    Ok(())
}

async fn clone_wiki(repo: &RepoName, api: &Forgejo, path: Option<PathBuf>) -> eyre::Result<()> {
    let repo_data = api.repo_get(repo.owner(), repo.name()).await?;
    let clone_url = repo_data
        .clone_url
        .as_ref()
        .ok_or_eyre("repo does not have clone url")?;
    let git_stripped = clone_url
        .as_str()
        .strip_suffix(".git")
        .unwrap_or(clone_url.as_str());
    let clone_url = url::Url::parse(&format!("{}.wiki.git", git_stripped))?;

    let repo_name = repo_data
        .name
        .as_deref()
        .ok_or_eyre("repo does not have name")?;
    let repo_full_name = repo_data
        .full_name
        .as_deref()
        .ok_or_eyre("repo does not have full name")?;
    let name = format!("{}'s wiki", repo_full_name);

    let path = path.unwrap_or_else(|| PathBuf::from(format!("./{repo_name}-wiki")));

    crate::repo::clone_repo(&name, &clone_url, &path)?;

    Ok(())
}
07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!760 blocks
openSUSE Build Service is sponsored by