diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..6f34b48
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,52 @@
+name: Bug Report
+description: Send a bug report
+title: "[Bug]: "
+labels: ["bug", "triage"]
+assignees:
+ - taorepoara
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this bug report!
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: Also tell us, what did you expect to happen?
+ placeholder: Tell us what you see!
+ value: "A bug happened!"
+ validations:
+ required: true
+ - type: dropdown
+ id: browsers
+ attributes:
+ label: What browsers are you seeing the problem on?
+ multiple: true
+ options:
+ - Firefox
+ - Chrome (or chrome-based)
+ - Safari
+ - Microsoft Edge
+ - Other (specify above)
+ - type: input
+ id: version
+ attributes:
+ label: Version
+ description: What version of our software are you running?
+ validations:
+ required: false
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant log output
+ description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
+ render: Shell
+ # - type: checkboxes
+ # id: terms
+ # attributes:
+ # label: Code of Conduct
+ # description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com)
+ # options:
+ # - label: I agree to follow this project's Code of Conduct
+ # required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..5b0669e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,6 @@
+
+blank_issues_enabled: true
+contact_links:
+ - name: Feature request
+ url: https://github.com/orgs/lenra-io/discussions/categories/ideas?discussions_q=category%3AIdeas+is%3Aunlocked
+ about: Please send your feature request in the discussion tab !
diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md
new file mode 100644
index 0000000..9d60646
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/task.md
@@ -0,0 +1,26 @@
+---
+name: Task
+about: Create a new task. Technical task should be small enough to be done by only
+ one person in a reasonable time.
+title: "[Task] "
+labels: enhancement
+assignees: ''
+
+---
+
+## What should be done
+Describe the task and scope...
+
+## Technical recommandation
+Any technical recommandation or specific warning about this task (if needed)
+- Security issue to be warned of
+- Complexity issue
+
+## Ecological concerns
+What can be done to improve the ecological impact of this task ?
+- Can we minimize the data sent to the network ?
+- Are data stored ? Can we minimize the amount of stored data ?
+- Is it CPU-intensive ? Can we reduce the CPU charge ?
+- Is it RAM-intensive ? Can we reduce the RAM load ?
+
+## Is this task linked with any other ?
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..e8d486a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "cargo" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/build_ci.yml b/.github/workflows/build_ci.yml
new file mode 100644
index 0000000..4eaf624
--- /dev/null
+++ b/.github/workflows/build_ci.yml
@@ -0,0 +1,258 @@
+name: Rust
+
+on:
+ push:
+ branches:
+ - main
+ - beta
+ paths-ignore:
+ - '*.md'
+ pull_request:
+ paths-ignore:
+ - '*.md'
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ get-next-version:
+ name: Get next version
+ runs-on: ubuntu-20.04
+ continue-on-error: true # must be allow to fail
+ timeout-minutes: 2
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16
+
+
+ - id: get-next-version
+ name: Get next version
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: npx -p conventional-changelog-conventionalcommits@5 -p @semantic-release/git -p semantic-release-export-data -p semantic-release@19 semantic-release --dry-run
+
+ outputs:
+ new-release-published: ${{ steps.get-next-version.outputs.new-release-published }}
+ new-release-version: ${{ steps.get-next-version.outputs.new-release-version }}
+
+ style:
+ name: Check Style
+ runs-on: ubuntu-20.04
+ timeout-minutes: 2
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Install rust
+ uses: actions-rs/toolchain@v1
+ with:
+ toolchain: stable
+ components: rustfmt
+ profile: minimal
+ override: true
+
+ - name: cargo fmt -- --check
+ uses: actions-rs/cargo@v1
+ with:
+ command: fmt
+ args: --all -- --check
+
+ test:
+ name: Test
+ needs: [style]
+ runs-on: ubuntu-20.04
+ timeout-minutes: 5
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v1
+
+ - name: Install rust
+ uses: actions-rs/toolchain@v1
+ with:
+ toolchain: nightly
+ profile: minimal
+ override: true
+
+ - name: Build debug
+ uses: actions-rs/cargo@v1
+ with:
+ command: build
+ args: --verbose
+
+ - name: Test
+ uses: actions-rs/cargo@v1
+ with:
+ command: test
+ args: --verbose
+
+ build:
+ name: Build ${{ matrix.os }} ${{ matrix.arch }}
+ needs: [test, get-next-version]
+ runs-on: "${{ matrix.runner }}"
+ env:
+ VERSION: ${{ needs.get-next-version.outputs.new-release-version }}
+ timeout-minutes: 20
+ strategy:
+ matrix: # Need to find what's the best target for `x86-x64-linux` and remove the others (gnu or musl)
+ include:
+ - target: aarch64-unknown-linux-musl
+ os: linux
+ arch: aarch64
+ runner: ubuntu-20.04
+ - target: x86_64-unknown-linux-musl
+ os: linux
+ arch: x86_64
+ runner: ubuntu-20.04
+ - target: x86_64-pc-windows-msvc
+ os: windows
+ arch: x86_64
+ runner: windows-2022
+ file_extension: '.exe'
+ # - target: aarch64-pc-windows-msvc
+ # os: windows
+ # arch: aarch64
+ # runner: windows-2022
+ # file_extension: '.exe'
+ - target: x86_64-apple-darwin
+ os: macos
+ arch: x86_64
+ runner: macos-11
+ - target: aarch64-apple-darwin
+ os: macos
+ arch: aarch64
+ runner: macos-11
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Install rust
+ uses: actions-rs/toolchain@v1
+ with:
+ toolchain: stable
+ profile: minimal
+ override: true
+ target: ${{ matrix.target }}
+
+ - name: Install cargo-edit
+ uses: actions-rs/cargo@v1
+ with:
+ command: install
+ args: cargo-edit
+
+ - name: Set version
+ uses: actions-rs/cargo@v1
+ with:
+ command: set-version
+ args: ${{ env.VERSION }}
+
+ - name: Build target
+ uses: actions-rs/cargo@v1
+ with:
+ use-cross: true
+ command: build
+ args: --release --target ${{ matrix.target }}
+
+ - name: Zip
+ if: ${{ matrix.os == 'windows' }}
+ shell: pwsh
+ run: Compress-Archive "target/${{ matrix.target }}/release/lenra${{ matrix.file_extension }}" "lenra-${{ matrix.os }}-${{ matrix.arch }}.zip"
+ - name: Zip
+ if: ${{ matrix.os != 'windows' }}
+ shell: bash
+ run: tar -C "target/${{ matrix.target }}/release" -czf "lenra-${{ matrix.os }}-${{ matrix.arch }}.tar.gz" "lenra${{ matrix.file_extension }}"
+
+ - name: Upload
+ uses: actions/upload-artifact@v3
+ with:
+ name: lenra-${{ matrix.os }}-${{ matrix.arch }}
+ path: lenra-${{ matrix.os }}-${{ matrix.arch }}.*
+
+ generate_doc_artifact:
+ name: Generate doc artifact
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: read
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+ - name: Setup Node.js
+ uses: actions/setup-node@v2
+ with:
+ node-version: "16"
+ - name: Setup node deps
+ run: |
+ cd docs-page-builder/
+ npm install
+ npm run build
+ - name: Compress tar.gz
+ run: |
+ cd docs-page-builder/build/
+ tar czvf lenra-cli-docs.tar.gz *
+ mv lenra-cli-docs.tar.gz ../..
+ - name: Compress zip
+ run: |
+ cd docs-page-builder/build/
+ zip -r lenra-cli-docs.zip *
+ mv lenra-cli-docs.zip ../..
+ - name: Documentation artifact
+ uses: actions/upload-artifact@v3
+ with:
+ name: lenra-cli-docs
+ path: |
+ lenra-cli-docs.tar.gz
+ lenra-cli-docs.zip
+
+ publish:
+ name: publish
+ needs: [build, generate_doc_artifact, get-next-version]
+ if: github.ref_name == 'main' || github.ref_name == 'beta' || github.ref_name == 'alpha'
+ runs-on: ubuntu-latest
+ env:
+ VERSION: ${{ needs.get-next-version.outputs.new-release-version }}
+ timeout-minutes: 8
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: download-artifacts
+ uses: actions/download-artifact@v3
+ with:
+ path: artifacts/
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v2
+ with:
+ node-version: "18"
+
+ - id: release
+ name: Release
+ shell: bash
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: npx --force -p conventional-changelog-conventionalcommits@5 -p @semantic-release/git -p semantic-release-export-data -p https://github.com/Tlepel/semantic-release.git#fix-versions-share-head semantic-release
+
+ - name: Install cargo-edit
+ if: ${{ steps.release.outputs.new-release-published }}
+ uses: actions-rs/cargo@v1
+ with:
+ command: install
+ args: cargo-edit
+
+ - name: Set version
+ if: ${{ steps.release.outputs.new-release-published }}
+ uses: actions-rs/cargo@v1
+ with:
+ command: set-version
+ args: ${{ env.VERSION }}
+
+ - name: Publish cargo
+ shell: bash
+ if: ${{ steps.release.outputs.new-release-published }}
+ run: cargo publish --allow-dirty --token "${{ secrets.CARGO_TOKEN }}"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bef4516
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.vscode
+/target
diff --git a/.releaserc.yml b/.releaserc.yml
new file mode 100644
index 0000000..afad749
--- /dev/null
+++ b/.releaserc.yml
@@ -0,0 +1,19 @@
+---
+branches:
+ - "+([0-9])?(.{+([0-9]),x}).x"
+ - main
+ - name: beta
+ prerelease: true
+plugins:
+ - - "@semantic-release/commit-analyzer"
+ - preset: conventionalcommits
+ - - "@semantic-release/release-notes-generator"
+ - preset: conventionalcommits
+ - - "@semantic-release/github"
+ - assets:
+ - path: artifacts/*/*
+ - path: lenra-cli-docs/lenra-cli-docs.zip
+ name: lenra-cli-docs-${nextRelease.gitTag}.zip
+ - path: lenra-cli-docs/lenra-cli-docs.tar.gz
+ name: lenra-cli-docs-${nextRelease.gitTag}.tar.gz
+ - - "semantic-release-export-data"
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..f4715e7
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1702 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "addr2line"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
+[[package]]
+name = "aho-corasick"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f58811cfac344940f1a400b6e6231ce35171f614f26439e80f8c1465c5cc0c"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b84bf0a05bbb2a83e5eb6fa36bb6e87baa08193c35ff52bbf6b38d8af2890e46"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "938874ff5980b03a87c5524b3ae5b59cf99b1d6bc836848df7bc5ada9643c333"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58f54d10c6dfa51283a066ceab3ec1ab78d13fae00aa49243a45e4571fb79dfd"
+dependencies = [
+ "anstyle",
+ "windows-sys",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.72"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc6dde6e4ed435a4c1ee4e73592f5ba9da2151af10076cc04858746af9352d09"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.27",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
+
+[[package]]
+name = "backtrace"
+version = "0.3.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
+[[package]]
+name = "base64"
+version = "0.21.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42"
+
+[[package]]
+name = "bumpalo"
+version = "3.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
+
+[[package]]
+name = "bytes"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be"
+
+[[package]]
+name = "cc"
+version = "1.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chrono"
+version = "0.4.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "time",
+ "wasm-bindgen",
+ "winapi",
+]
+
+[[package]]
+name = "clap"
+version = "4.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1d7b8d5ec32af0fadc644bf1fd509a688c2103b185644bb1e29d164e0703136"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5179bb514e4d7c2051749d8fcefa2ed6d06a9f4e6d69faf3805f5d80b8cf8d56"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0862016ff20d69b84ef8247369fabf5c008a7417002411897d40ee1f4532b873"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.27",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961"
+
+[[package]]
+name = "clipboard-win"
+version = "4.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362"
+dependencies = [
+ "error-code",
+ "str-buf",
+ "winapi",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+
+[[package]]
+name = "colored"
+version = "2.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6"
+dependencies = [
+ "is-terminal",
+ "lazy_static",
+ "windows-sys",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
+
+[[package]]
+name = "crc32fast"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "darling"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "derive_builder"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e"
+dependencies = [
+ "derive_builder_core",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-next"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
+dependencies = [
+ "cfg-if",
+ "dirs-sys-next",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys",
+]
+
+[[package]]
+name = "dirs-sys-next"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "docker-compose-types"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee50caf01e7a34feba906df016260b8850ccc7f5b3cf3a7e7841349af071e8b"
+dependencies = [
+ "derive_builder",
+ "indexmap 1.9.3",
+ "serde",
+ "serde_yaml",
+]
+
+[[package]]
+name = "dofigen"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77b39d241f5d272b78696bfc0fa06b94d92fcb0cb8b6cfd316f412fe8dd6cf72"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "thiserror",
+]
+
+[[package]]
+name = "either"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
+
+[[package]]
+name = "endian-type"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
+
+[[package]]
+name = "env_logger"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85cdab6a89accf66733ad5a1693a4dcced6aeff64602b634530dd73c1f3ee9f0"
+dependencies = [
+ "humantime",
+ "is-terminal",
+ "log",
+ "regex",
+ "termcolor",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "errno"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a"
+dependencies = [
+ "errno-dragonfly",
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "errno-dragonfly"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "error-code"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21"
+dependencies = [
+ "libc",
+ "str-buf",
+]
+
+[[package]]
+name = "fd-lock"
+version = "3.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef033ed5e9bad94e55838ca0ca906db0e043f517adda0c8b79c7a8c66c93c1b5"
+dependencies = [
+ "cfg-if",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "flate2"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743"
+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 = "form_urlencoded"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.27",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65"
+
+[[package]]
+name = "futures-timer"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c"
+
+[[package]]
+name = "futures-util"
+version = "0.3.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "gimli"
+version = "0.27.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e"
+
+[[package]]
+name = "glob"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "443144c8cdadd93ebf52ddb4056d257f5b52c04d3c804e657d19eb73fc33668b"
+
+[[package]]
+name = "humantime"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "wasm-bindgen",
+ "windows",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.14.0",
+]
+
+[[package]]
+name = "is-terminal"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b"
+dependencies = [
+ "hermit-abi",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "itertools"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
+
+[[package]]
+name = "js-sys"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "lenra_cli"
+version = "0.0.0"
+dependencies = [
+ "async-trait",
+ "chrono",
+ "clap",
+ "colored",
+ "dirs",
+ "docker-compose-types",
+ "dofigen",
+ "env_logger",
+ "itertools",
+ "lazy_static",
+ "loading",
+ "log",
+ "mocktopus",
+ "regex",
+ "rstest",
+ "rustyline",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "strum",
+ "thiserror",
+ "tokio",
+ "ureq",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.147"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0"
+
+[[package]]
+name = "loading"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "676420b03272176423578148eee61319e893422694f6d96a394841cb0901357f"
+
+[[package]]
+name = "lock_api"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4"
+
+[[package]]
+name = "memchr"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7"
+dependencies = [
+ "adler",
+]
+
+[[package]]
+name = "mio"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2"
+dependencies = [
+ "libc",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+ "windows-sys",
+]
+
+[[package]]
+name = "mocktopus"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e4f0d5a1621fea252541cf67533c4b9c32ee892d790768f4ad48f1063059537"
+dependencies = [
+ "mocktopus_macros",
+]
+
+[[package]]
+name = "mocktopus_macros"
+version = "0.7.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3048ef3680533a27f9f8e7d6a0bce44dc61e4895ea0f42709337fa1c8616fefe"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
+[[package]]
+name = "nibble_vec"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
+dependencies = [
+ "smallvec",
+]
+
+[[package]]
+name = "nix"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
+dependencies = [
+ "autocfg",
+ "bitflags 1.3.2",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "object"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.3.5",
+ "smallvec",
+ "windows-targets",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "radix_trie"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
+dependencies = [
+ "endian-type",
+ "nibble_vec",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
+dependencies = [
+ "getrandom",
+ "redox_syscall 0.2.16",
+ "thiserror",
+]
+
+[[package]]
+name = "regex"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39354c10dd07468c2e73926b23bb9c2caca74c5501e38a35da70406f1d923310"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
+
+[[package]]
+name = "relative-path"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c707298afce11da2efef2f600116fa93ffa7a032b5d7b628aa17711ec81383ca"
+
+[[package]]
+name = "ring"
+version = "0.16.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
+dependencies = [
+ "cc",
+ "libc",
+ "once_cell",
+ "spin",
+ "untrusted",
+ "web-sys",
+ "winapi",
+]
+
+[[package]]
+name = "rstest"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199"
+dependencies = [
+ "futures",
+ "futures-timer",
+ "rstest_macros",
+ "rustc_version",
+]
+
+[[package]]
+name = "rstest_macros"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605"
+dependencies = [
+ "cfg-if",
+ "glob",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "relative-path",
+ "rustc_version",
+ "syn 2.0.27",
+ "unicode-ident",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5"
+dependencies = [
+ "bitflags 2.3.3",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustls"
+version = "0.21.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79ea77c539259495ce8ca47f53e66ae0330a8819f67e23ac96ca02f50e7b7d36"
+dependencies = [
+ "log",
+ "ring",
+ "rustls-webpki 0.101.1",
+ "sct",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.100.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6207cd5ed3d8dca7816f8f3725513a34609c0c765bf652b8c3cb4cfd87db46b"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.101.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15f36a6828982f422756984e47912a7a51dcbc2a197aa791158f8ca61cd8204e"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
+
+[[package]]
+name = "rustyline"
+version = "10.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1e83c32c3f3c33b08496e0d1df9ea8c64d39adb8eb36a1ebb1440c690697aef"
+dependencies = [
+ "bitflags 1.3.2",
+ "cfg-if",
+ "clipboard-win",
+ "dirs-next",
+ "fd-lock",
+ "libc",
+ "log",
+ "memchr",
+ "nix",
+ "radix_trie",
+ "scopeguard",
+ "unicode-segmentation",
+ "unicode-width",
+ "utf8parse",
+ "winapi",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sct"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918"
+
+[[package]]
+name = "serde"
+version = "1.0.175"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d25439cd7397d044e2748a6fe2432b5e85db703d6d097bd014b3c0ad1ebff0b"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.175"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b23f7ade6f110613c0d63858ddb8b94c1041f550eab58a16b371bdf2c9c80ab4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.27",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d03b412469450d4404fe8499a268edd7f8b79fecb074b0d812ad64ca21f4031b"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.9.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a49e178e4452f45cb61d0cd8cebc1b0fafd3e41929e996cef79aa3aca91f574"
+dependencies = [
+ "indexmap 2.0.0",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
+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 = "smallvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
+
+[[package]]
+name = "socket2"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "spin"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
+
+[[package]]
+name = "str-buf"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0"
+
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
+[[package]]
+name = "strum"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6069ca09d878a33f883cc06aaa9718ede171841d3832450354410b718b097232"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.27",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b60f673f44a8255b9c8c657daf66a596d435f2da81a555b06dc644d080ba45e0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "611040a08a0439f8248d1990b111c95baa9c704c805fa1f62104b39655fd7f90"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "090198534930841fab3a5d1bb637cde49e339654e606195f8d9c76eeb081dc96"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.27",
+]
+
+[[package]]
+name = "time"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a"
+dependencies = [
+ "libc",
+ "wasi 0.10.0+wasi-snapshot-preview1",
+ "winapi",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+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.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da"
+dependencies = [
+ "autocfg",
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio",
+ "num_cpus",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.27",
+]
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
+
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28467d3e1d3c6586d8f25fa243f544f5800fec42d97032474e17222c2b75cfa"
+
+[[package]]
+name = "untrusted"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+
+[[package]]
+name = "ureq"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b11c96ac7ee530603dcdf68ed1557050f374ce55a5a07193ebf8cbc9f8927e9"
+dependencies = [
+ "base64",
+ "flate2",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-webpki 0.100.1",
+ "serde",
+ "serde_json",
+ "url",
+ "webpki-roots",
+]
+
+[[package]]
+name = "url"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
+name = "utf8parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+
+[[package]]
+name = "wasi"
+version = "0.10.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.27",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.27",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
+
+[[package]]
+name = "web-sys"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338"
+dependencies = [
+ "rustls-webpki 0.100.1",
+]
+
+[[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.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
+dependencies = [
+ "winapi",
+]
+
+[[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"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..34480b2
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,47 @@
+[package]
+name = "lenra_cli"
+version = "0.0.0"
+edition = "2021"
+license = "MIT"
+description = "The Lenra command line interface"
+repository = "https://github.com/lenra-io/lenra_cli"
+keywords = ["cli", "lenra"]
+categories = ["command-line-utilities"]
+include = [
+ "**/*.rs",
+ "Cargo.toml",
+]
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[[bin]]
+edition = "2021"
+name = "lenra"
+path = "src/main.rs"
+
+[dependencies]
+clap = { version = "4.4", features = ["derive"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+serde_yaml = "0.9.19"
+log = "0.4.17"
+env_logger = "0.10.0"
+regex = "1.8.2"
+lazy_static = "1.4.0"
+dofigen = "1.2.0"
+docker-compose-types = "0.4.1"
+rustyline = "10.1.0"
+dirs = "5.0.0"
+chrono = "0.4.24"
+thiserror = "1.0.40"
+colored = "2.0.0"
+ureq = { version = "2.6.2", features = ["json"] }
+tokio = { version = "1.27.0", features = ["full"] }
+async-trait = "0.1.68"
+strum = { version = "0.25.0", features = ["strum_macros", "derive"] }
+itertools = "0.11.0"
+loading = "0.3.0"
+
+[dev-dependencies]
+mocktopus = "0.8.0"
+rstest = "0.18.2"
diff --git a/README.md b/README.md
index 5fc8690..bb654b7 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,112 @@
-# lenra-cli
-The Lenra's command line interface
+
+
+
+
+
+[![Contributors][contributors-shield]][contributors-url]
+[![Forks][forks-shield]][forks-url]
+[![Stargazers][stars-shield]][stars-url]
+[![Issues][issues-shield]][issues-url]
+[![MIT License][license-shield]][license-url]
+
+
+
+
+
+
+
+
Lenra CLI
+
+
+ Lenra is an open-source and ethical alternative to Firebase. Optimize your app creation, and just like that, make the world a better place;)
+ Create / host and deploy in one place.
+
+
+[Report Bug](https://github.com/lenra-io/lenra_cli/issues)
+·
+[Request Feature](https://github.com/lenra-io/lenra_cli/issues)
+
+
+
+
+
+
+
+
+The Lenra's command line interface helps you building your Lenra app locally.
+
+
+## What is Lenra
+
+Lenra is an open source framework to create your app using any language, and deploy it without any Ops scale, built on ethical values.
+
+[Discover our framework](https://www.lenra.io/discover.html)
+
+
+
+
+
+
+
+## Getting Started
+
+To start using Lenra to build your app, [install the CLI](install.md) and learn [how to use it](docs/index.md).
+
+(back to top )
+
+
+
+## Contributing
+
+Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
+
+If you have a suggestion that would make this better, please open an issue with the tag "enhancement" or "bug".
+Don't forget to give the project a star! Thanks again!
+
+### Run tests
+
+In order to have more advanced unit tests, we use [Mocktopus](https://github.com/CodeSandwich/Mocktopus) that is based on the nightly Rust toolchain.
+To run them you have to install the toolchain and run them with it:
+
+```bash
+rustup install nightly
+cargo +nightly test
+```
+
+(back to top )
+
+
+
+
+## License
+
+Distributed under the **MIT** License. See [LICENSE](./LICENSE) for more information.
+
+(back to top )
+
+
+
+
+## Contact
+
+Lenra - [@lenra_dev](https://twitter.com/lenra_dev) - contact@lenra.io
+
+Project Link: [https://github.com/lenra-io/lenra_cli](https://github.com/lenra-io/lenra_cli)
+
+(back to top )
+
+
+
+
+[contributors-shield]: https://img.shields.io/github/contributors/lenra-io/lenra_cli.svg?style=for-the-badge
+[contributors-url]: https://github.com/lenra-io/lenra_cli/graphs/contributors
+[forks-shield]: https://img.shields.io/github/forks/lenra-io/lenra_cli.svg?style=for-the-badge
+[forks-url]: https://github.com/lenra-io/lenra_cli/network/members
+[stars-shield]: https://img.shields.io/github/stars/lenra-io/lenra_cli.svg?style=for-the-badge
+[stars-url]: https://github.com/lenra-io/lenra_cli/stargazers
+[issues-shield]: https://img.shields.io/github/issues/lenra-io/lenra_cli.svg?style=for-the-badge
+[issues-url]: https://github.com/lenra-io/lenra_cli/issues
+[license-shield]: https://img.shields.io/github/license/lenra-io/lenra_cli.svg?style=for-the-badge
+[license-url]: https://github.com/lenra-io/lenra_cli/blob/master/LICENSE.txt
diff --git a/docs-page-builder/.gitignore b/docs-page-builder/.gitignore
new file mode 100644
index 0000000..b51ea71
--- /dev/null
+++ b/docs-page-builder/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+build/
\ No newline at end of file
diff --git a/docs-page-builder/build.js b/docs-page-builder/build.js
new file mode 100644
index 0000000..0024564
--- /dev/null
+++ b/docs-page-builder/build.js
@@ -0,0 +1,63 @@
+import * as fs from 'fs/promises';
+import Showdown from 'showdown';
+import fm from 'front-matter';
+import path from 'path';
+
+const converter = new Showdown.Converter({ tables: true });
+converter.addExtension({
+ type: 'output',
+ filter: function (text) {
+ const replaced = text
+ // Replace markdown links to html
+ .replace(/href="([^"]+)"/g, (_, href) => `href="${href.replace(/\.md(#|$)/, '.html$1')}"`);
+ return replaced;
+ }
+});
+
+const githubBasPath = "https://github.com/lenra-io/lenra_cli/blob/beta/";
+
+const docsDir = path.join("..", "docs");
+const outDir = "build";
+
+async function buildFiles(src, dest) {
+ const files = await fs.readdir(src, { withFileTypes: true });
+ const promises = files.map(info => {
+ const filePath = path.join(src, info.name);
+ if (info.isFile()) return buildFile(filePath, info.name, dest);
+ if (!info.isDirectory()) throw new Error("Error building", filePath);
+ return buildFiles(filePath, path.join(dest, info.name));
+ });
+ return Promise.all(promises);
+}
+
+/**
+ * Build the given file to the target directory
+ * @param {string} src
+ * @param {string} filename
+ * @param {string} destDir
+ */
+async function buildFile(src, filename, destDir) {
+ await fs.mkdir(destDir, { recursive: true });
+ if (filename.endsWith(".md")) {
+ console.log(`buiding ${src} to ${destDir}`);
+ const fmResult = fm(await fs.readFile(src, 'utf8'));
+ const baseName = filename.replace(/.md$/, "");
+ const name = fmResult.attributes.name ?? (filename === "index.md" ? undefined : baseName);
+ const destFile = path.join(destDir, `${baseName}.html`);
+ const destJsonFile = destFile + '.json';
+ return Promise.all([
+ fs.writeFile(destFile, converter.makeHtml(fmResult.body), 'utf8'),
+ fs.writeFile(destJsonFile, JSON.stringify({
+ ...fmResult.attributes,
+ name,
+ sourceFile: githubBasPath + src.replace(/^..\//, '')
+ }), 'utf8')
+ ]);
+ }
+ else {
+ return fs.copyFile(src, path.join(destDir, filename));
+ }
+}
+
+console.log("Start building doc pages");
+buildFiles(docsDir, outDir).then(_ => console.log("Doc pages built"));
diff --git a/docs-page-builder/package-lock.json b/docs-page-builder/package-lock.json
new file mode 100644
index 0000000..71e3cc6
--- /dev/null
+++ b/docs-page-builder/package-lock.json
@@ -0,0 +1,92 @@
+{
+ "name": "docs-page-builder",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "docs-page-builder",
+ "version": "1.0.0",
+ "license": "MIT",
+ "devDependencies": {
+ "front-matter": "^4.0.2",
+ "showdown": "^2.1.0"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/commander": {
+ "version": "9.5.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
+ "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20.0 || >=14"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/front-matter": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz",
+ "integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==",
+ "dev": true,
+ "dependencies": {
+ "js-yaml": "^3.13.1"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/showdown": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz",
+ "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
+ "dev": true,
+ "dependencies": {
+ "commander": "^9.0.0"
+ },
+ "bin": {
+ "showdown": "bin/showdown.js"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://www.paypal.me/tiviesantos"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "dev": true
+ }
+ }
+}
diff --git a/docs-page-builder/package.json b/docs-page-builder/package.json
new file mode 100644
index 0000000..1a3875f
--- /dev/null
+++ b/docs-page-builder/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "docs-page-builder",
+ "version": "1.0.0",
+ "description": "Generates HTML pages from markdown files",
+ "type": "module",
+ "main": "build.js",
+ "scripts": {
+ "build": "node build.js",
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "author": "Lenra",
+ "license": "MIT",
+ "devDependencies": {
+ "front-matter": "^4.0.2",
+ "showdown": "^2.1.0"
+ }
+}
\ No newline at end of file
diff --git a/docs/commands/build.md b/docs/commands/build.md
new file mode 100644
index 0000000..4b5a7d4
--- /dev/null
+++ b/docs/commands/build.md
@@ -0,0 +1,22 @@
+---
+description: This subcommand builds the Lenra app of the current directory.
+---
+
+This subcommand builds the Lenra app of the current directory.
+The app configuration is defined by a [configuration file](#configuration-file).
+
+```bash
+$ lenra build --help
+lenra-build
+Build your app in release mode
+
+USAGE:
+ lenra build [OPTIONS]
+
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -h, --help Print help information
+ --production Remove debug access to the app
+ -v, --verbose Run the commands as verbose
+```
diff --git a/docs/commands/check/index.md b/docs/commands/check/index.md
new file mode 100644
index 0000000..69db697
--- /dev/null
+++ b/docs/commands/check/index.md
@@ -0,0 +1,27 @@
+---
+description: This subcommands check the running app.
+---
+
+This subcommands check the running app.
+
+```bash
+$ lenra check --help
+lenra-check
+Checks the running app
+
+USAGE:
+ lenra check
+
+OPTIONS:
+ -h, --help Print help information
+
+SUBCOMMANDS:
+ help Print this message or the help of the given subcommand(s)
+ template Checks the current project as a template
+```
+
+## Subcommands
+
+This tool contains many subcommands to help you doing what you need.
+
+- [template](./template.md): checks the current project as a template
\ No newline at end of file
diff --git a/docs/commands/check/template.md b/docs/commands/check/template.md
new file mode 100644
index 0000000..98168e7
--- /dev/null
+++ b/docs/commands/check/template.md
@@ -0,0 +1,22 @@
+---
+description: This subcommands checks the running app as a Lenra template app.
+---
+
+This subcommands checks the running app as a Lenra template app.
+
+```bash
+$ lenra check template --help
+lenra-check-template
+Checks the current project as a template
+
+USAGE:
+ lenra check template [OPTIONS] [RULES]...
+
+ARGS:
+ ... The rules
+
+OPTIONS:
+ -h, --help Print help information
+ --ignore A list of rules to ignore
+ --strict The strict mode also fails with warning rules
+```
\ No newline at end of file
diff --git a/docs/commands/dev.md b/docs/commands/dev.md
new file mode 100644
index 0000000..3e60c01
--- /dev/null
+++ b/docs/commands/dev.md
@@ -0,0 +1,35 @@
+---
+description: This subcommand starts the Lenra app of the current directory in dev mode.
+---
+
+This subcommand starts the Lenra app of the current directory in dev mode.
+
+The dev mode builds and starts the app and then displays its logs.
+
+```bash
+$ lenra dev --help
+lenra-dev
+Start the app in an interactive mode
+
+USAGE:
+ lenra dev [OPTIONS]
+
+OPTIONS:
+ --attach Attach the dev mode without rebuilding the app and restarting it
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -h, --help Print help information
+ -v, --verbose Run the commands as verbose
+```
+
+When your app is in dev mode, you can run interactive commands through keyboard shortcuts.
+
+Here is the help interactive command result displayed pressing `H` key:
+
+```bash
+SHORTCUTS: (Command Key(s) Description)
+ Help H Print this message
+ Reload R Reload the app by rebuilding and restarting it
+ Quit Q, Ctrl+C Quit the interactive mode
+ Stop S Stop your app previously started with the start command
+```
diff --git a/docs/commands/index.md b/docs/commands/index.md
new file mode 100644
index 0000000..39c9242
--- /dev/null
+++ b/docs/commands/index.md
@@ -0,0 +1,59 @@
+---
+description: This CLI contains many subcommands to help you doing what you need.
+---
+
+This CLI contains many subcommands to help you doing what you need.
+
+- [new](./new.md): creates a new Lenra app project from a template
+- [dev](./dev/index.md): starts your app in dev mode
+- [update](./update.md): updates the tools Docker images
+- [upgrade](./upgrade.md): upgrades the app with the last template updates
+- [build](./build.md): builds the Lenra app of the current directory
+- [start](./start.md): starts your app previously built with the build command
+- [reload](./reload.md): starts your app previously built with the build command
+- [logs](./logs.md): displays output from the containers
+- [stop](./stop.md): stops your app previously started with the start command
+- [check](./check/index.md): checks the running app
+
+Use the help options or help subcommand to understand how to use them:
+
+```bash
+$ lenra --help
+lenra_cli 0.0.0
+The Lenra command line interface
+
+USAGE:
+ lenra [OPTIONS] [SUBCOMMAND]
+
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -h, --help Print help information
+ -v, --verbose Run the commands as verbose
+ -V, --version Print version information
+
+SUBCOMMANDS:
+ build Build your app in release mode
+ check Checks the running app
+ dev Start the app in an interactive mode
+ help Print this message or the help of the given subcommand(s)
+ logs View output from the containers
+ new Create a new Lenra app project from a template
+ reload Reload the app by rebuilding and restarting it
+ start Start your app previously built with the build command
+ stop Stop your app previously started with the start command
+ update Update the tools Docker images
+ upgrade Upgrade the app with the last template updates
+```
+
+Some global options are available for all subcommands:
+
+```bash
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -v, --verbose Run the commands as verbose
+```
+
+They won't have effect on all subcommands but can be used for most of them.
+Also, you will be able to set them in the [terminal context](./terminal/index.md) since they are defined for the whole terminal lifetime (except the `--expose` option that can be redefined by the [`expose` command](./terminal/expose.md)).
diff --git a/docs/commands/logs.md b/docs/commands/logs.md
new file mode 100644
index 0000000..a18981d
--- /dev/null
+++ b/docs/commands/logs.md
@@ -0,0 +1,34 @@
+---
+description: This subcommand displays output from the containers.
+---
+
+This subcommand displays output from the containers.
+
+```bash
+$ lenra logs --help
+lenra-logs
+View output from the containers
+
+USAGE:
+ lenra logs [OPTIONS] [SERVICES]...
+
+ARGS:
+ ... The logged service list [default: app] [possible values: app, devtool,
+ postgres, mongo]
+
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -f, --follow Follow log output
+ -h, --help Print help information
+ --no-color Produce monochrome output
+ --no-log-prefix Don't print prefix in logs
+ --since Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g.
+ 42m for 42 minutes)
+ -t, --timestamps Show timestamps
+ --tail Number of lines to show from the end of the logs for each container
+ [default: all]
+ --until Show logs before a timestamp (e.g. 2013-01-02T13:23:37Z) or relative
+ (e.g. 42m for 42 minutes)
+ -v, --verbose Run the commands as verbose
+```
diff --git a/docs/commands/new.md b/docs/commands/new.md
new file mode 100644
index 0000000..ed1f11e
--- /dev/null
+++ b/docs/commands/new.md
@@ -0,0 +1,30 @@
+---
+description: This subcommand creates a new Lenra app project from a template.
+---
+
+This subcommand creates a new Lenra app project from a template.
+The target directory must not exist.
+
+```bash
+$ lenra new --help
+lenra-new
+Create a new Lenra app project from a template
+
+USAGE:
+ lenra new [OPTIONS] [TOPICS]...
+
+ARGS:
+ ... The project template topics from which your project will be created. For
+ example, defining `rust` look for the next API endpoint:
+ https://api.github.com/search/repositories?q=topic:lenra+topic:template+topic:rust&sort=stargazers
+ You can find all the templates at this url:
+ https://github.com/search?q=topic%3Alenra+topic%3Atemplate&sort=stargazers&type=repositories
+ You also can set the template project full url to use custom ones
+
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -h, --help Print help information
+ -p, --path The new project path [default: .]
+ -v, --verbose Run the commands as verbose
+```
diff --git a/docs/commands/reload.md b/docs/commands/reload.md
new file mode 100644
index 0000000..1831e74
--- /dev/null
+++ b/docs/commands/reload.md
@@ -0,0 +1,20 @@
+---
+description: This subcommand reloads the app by rebuilding and restarting it.
+---
+
+This subcommand reloads the app by rebuilding and restarting it.
+
+```bash
+$ lenra reload --help
+lenra-reload
+Reload the app by rebuilding and restarting it
+
+USAGE:
+ lenra reload [OPTIONS]
+
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -h, --help Print help information
+ -v, --verbose Run the commands as verbose
+```
diff --git a/docs/commands/start.md b/docs/commands/start.md
new file mode 100644
index 0000000..01902a0
--- /dev/null
+++ b/docs/commands/start.md
@@ -0,0 +1,20 @@
+---
+description: This subcommand starts the Lenra app of the current directory previously built.
+---
+
+This subcommand starts the Lenra app of the current directory previously built.
+
+```bash
+$ lenra start --help
+lenra-start
+Start your app previously built with the build command
+
+USAGE:
+ lenra start [OPTIONS]
+
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -h, --help Print help information
+ -v, --verbose Run the commands as verbose
+```
diff --git a/docs/commands/stop.md b/docs/commands/stop.md
new file mode 100644
index 0000000..000be2e
--- /dev/null
+++ b/docs/commands/stop.md
@@ -0,0 +1,20 @@
+---
+description: This subcommand stops the Lenra app of the current directory and removes the Docker Compose elements.
+---
+
+This subcommand stops the Lenra app of the current directory and removes the Docker Compose elements.
+
+```bash
+$ lenra stop --help
+lenra-stop
+Stop your app previously started with the start command
+
+USAGE:
+ lenra stop [OPTIONS]
+
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -h, --help Print help information
+ -v, --verbose Run the commands as verbose
+```
diff --git a/docs/commands/terminal/exit.md b/docs/commands/terminal/exit.md
new file mode 100644
index 0000000..d880b54
--- /dev/null
+++ b/docs/commands/terminal/exit.md
@@ -0,0 +1,17 @@
+---
+description: This command let you exit from the terminal.
+---
+
+This command let you exit from the terminal. You also can use the `Ctrl + C` and `Ctrl + D` shortcuts.
+
+```bash
+[lenra]$ exit --help
+lenra-exit
+Exits the terminal
+
+USAGE:
+ lenra exit
+
+OPTIONS:
+ -h, --help Print help information
+```
diff --git a/docs/commands/terminal/expose.md b/docs/commands/terminal/expose.md
new file mode 100644
index 0000000..5a8598e
--- /dev/null
+++ b/docs/commands/terminal/expose.md
@@ -0,0 +1,21 @@
+---
+description: This command exposes the containers ports.
+---
+
+This command exposes the containers ports.
+
+```bash
+[lenra]$ expose --help
+lenra-expose
+Exposes the app ports
+
+USAGE:
+ lenra expose [SERVICES]...
+
+ARGS:
+ ... The service list to expose [default: app postgres mongo] [possible values:
+ app, devtool, postgres, mongo]
+
+OPTIONS:
+ -h, --help Print help information
+```
diff --git a/docs/commands/terminal/index.md b/docs/commands/terminal/index.md
new file mode 100644
index 0000000..98266fe
--- /dev/null
+++ b/docs/commands/terminal/index.md
@@ -0,0 +1,65 @@
+---
+description: The terminal is a command prompt that let you run only Lenra specific commands. It has his own history and keep the same command context during it lifetime.
+---
+
+The terminal is a command prompt that let you run only Lenra specific commands. It has his own history and keep the same command context during it lifetime.
+
+To start the terminal, run the `lenra` command without any subcommand:
+
+```bash
+$ lenra
+```
+
+You can use the global options to configure the terminal context:
+
+```bash
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -v, --verbose Run the commands as verbose
+```
+
+## Commands
+
+The terminal let run all the `lenra` subcommands (excpet the `terminal` itself and the `new` one) without the need of write `lenra` before them each time and even more:
+
+
+- [dev](../dev.md): starts your app in dev mode
+- [update](../update.md): updates the tools Docker images
+- [upgrade](../upgrade.md): upgrades the app with the last template updates
+- [build](../build.md): builds the Lenra app of the current directory
+- [start](../start.md): starts your app previously built with the build command
+- [reload](../reload.md): starts your app previously built with the build command
+- [logs](../logs.md): displays output from the containers
+- [stop](../stop.md): stops your app previously started with the start command
+- [check](../check/index.md): checks the running app
+- [expose](./expose.md): exposes the services ports and keep it in the terminal context
+- [exit](./exit.md): exits the terminal
+
+Here is the help result in the terminal:
+
+```bash
+[lenra]$ help
+lenra_cli
+The Lenra interactive command line interface
+
+USAGE:
+ lenra
+
+OPTIONS:
+ -h, --help Print help information
+
+SUBCOMMANDS:
+ build Build your app in release mode
+ check Checks the running app
+ dev Start the app in an interactive mode
+ exit Exits the terminal
+ expose Exposes the app ports
+ help Print this message or the help of the given subcommand(s)
+ logs View output from the containers
+ reload Reload the app by rebuilding and restarting it
+ start Start your app previously built with the build command
+ stop Stop your app previously started with the start command
+ update Update the tools Docker images
+ upgrade Upgrade the app with the last template updates
+```
diff --git a/docs/commands/update.md b/docs/commands/update.md
new file mode 100644
index 0000000..3c9dcad
--- /dev/null
+++ b/docs/commands/update.md
@@ -0,0 +1,24 @@
+---
+description: This subcommand updates the tools Docker images.
+---
+
+This subcommand updates the tools Docker images.
+
+```bash
+$ lenra update --help
+lenra-update
+Update the tools Docker images
+
+USAGE:
+ lenra update [OPTIONS] [SERVICES]...
+
+ARGS:
+ ... The service list to pull [default: devtool postgres mongo] [possible
+ values: app, devtool, postgres, mongo]
+
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -h, --help Print help information
+ -v, --verbose Run the commands as verbose
+```
diff --git a/docs/commands/upgrade.md b/docs/commands/upgrade.md
new file mode 100644
index 0000000..7257c21
--- /dev/null
+++ b/docs/commands/upgrade.md
@@ -0,0 +1,20 @@
+---
+description: This subcommand upgrades the app with the last template updates.
+---
+
+This subcommand upgrades the app with the last template updates.
+
+```bash
+$ lenra upgrade --help
+lenra-upgrade
+Upgrade the app with the last template updates
+
+USAGE:
+ lenra upgrade [OPTIONS]
+
+OPTIONS:
+ --config The app configuration file [default: lenra.yml]
+ --expose Exposes services ports [possible values: app, devtool, postgres, mongo]
+ -h, --help Print help information
+ -v, --verbose Run the commands as verbose
+```
diff --git a/docs/config-file.md b/docs/config-file.md
new file mode 100644
index 0000000..9c9ef2e
--- /dev/null
+++ b/docs/config-file.md
@@ -0,0 +1,44 @@
+---
+name: lenra.yml config file
+description: The Lenra's configuration file describes your Lenra app configurations, like API versions or how to build it.
+---
+
+The Lenra's configuration file describes your Lenra app configurations, like API versions or how to build it.
+
+Here is an example using a Dofigen file:
+
+```yaml
+path: "."
+generator:
+ dofigen: dofigen.yml
+```
+
+## Configuration
+
+The configuration is the main element of the file:
+
+| Field | Type | Description |
+| ----------- | ----------------------- | ------------------------------ |
+| `path` | String | The project path (default ".") |
+| `generator` | [Generator](#generator) | The generator configuration |
+
+## Generator
+
+The generator define your application is built. There are many configurators:
+
+- [Configuration](#configuration)
+- [Generator](#generator)
+ - [Dofigen](#dofigen)
+ - [Docker](#docker)
+
+### Dofigen
+
+The Dofigen generator use a [Dofigen](https://github.com/lenra-io/dofigen) configuration to generate the Docker image.
+
+The Dofigen configuration can be the path to a Dofigen file or it content directly.
+
+### Docker
+
+The Docker generator use a Dockerfile to generate the Docker image.
+
+The Dockerfile can be the path to a file or it content directly.
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..1bfee7a
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,35 @@
+---
+name: CLI
+description: The Lenra's command line interface helps you building your Lenra app locally and it's gona be your best friend !
+---
+
+The Lenra's command line interface helps you building your Lenra app locally and it's gona be your best friend !
+
+We've created many [commands](commands/index.md) to help you managing a local smaller Lenra instance to create and test your app.
+
+## Create a new app
+
+To create an app, you should use the [`lenra new` subcommand](commands/new.md) that will create a new project based on [a Lenra app template](https://github.com/orgs/lenra-io/repositories?q=&type=template&language=&sort=stargazers).
+
+Start it running the [`lenra dev` subcommand](commands/dev/index.md).
+Your app will be built, started and exposed on http://localhost:4000/
+
+Here are the steps to start building a JavaScript Lenra app:
+
+```bash
+# new app from javascript template in a new 'my-app' directory
+lenra new javascript -p my-app
+# move to the new app dir
+cd my-app
+# initialize git versionning
+git init
+# start your app
+lenra dev
+```
+
+Look the [`lenra dev` subcommand](commands/dev/index.md) to understand the new dev mode.
+
+## Configure your app container
+
+Lenra app system is based containers.
+Look at our [`lenra.yml` config file](config-file.md) to adapt your app configuration and better understand how you can tools in your app containers.
\ No newline at end of file
diff --git a/install.md b/install.md
new file mode 100644
index 0000000..272c303
--- /dev/null
+++ b/install.md
@@ -0,0 +1,46 @@
+## Prerequisites
+
+To build and run the Lenra elements that handle your app, the Lenra CLI needs [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/).
+
+To create a new project and upgrade it later, the CLI also needs [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) (that we hope you already use ^^).
+
+You can also install the [Docker buildx command](https://docs.docker.com/build/buildx/install/) to use the [Buildkit optimization given by Dofigen](https://github.com/lenra-io/dofigen).
+
+Install the Lenra CLI using one of the next possibilities.
+
+## Install
+
+### Download the binary
+
+You can download the binary from [the release page](https://github.com/lenra-io/lenra_cli/releases) and add it to your path environment variable.
+
+### Cargo install
+
+First install Cargo, the Rust package manager: https://doc.rust-lang.org/cargo/getting-started/installation.html
+
+Then use the next command to install the Lenra's cli:
+
+```bash
+cargo install lenra_cli
+```
+
+Since the CLI is not released yet, you have to target a [pre-release version](https://github.com/lenra-io/lenra_cli/releases) like that:
+
+```bash
+cargo install lenra_cli@~1.0.0-beta.0
+```
+
+### Build it from sources
+
+First install Cargo, the Rust package manager: https://doc.rust-lang.org/cargo/getting-started/installation.html
+
+Then clone this repository and install it with Cargo:
+
+```bash
+git clone https://github.com/lenra-io/lenra_cli.git
+cargo install --path .
+```
+
+## And now ?
+
+Learn how to use it with [our docs website](https://docs.lenra.io).
\ No newline at end of file
diff --git a/src/cli/build.rs b/src/cli/build.rs
new file mode 100644
index 0000000..f57ddfe
--- /dev/null
+++ b/src/cli/build.rs
@@ -0,0 +1,44 @@
+use async_trait::async_trait;
+
+use clap;
+
+use crate::cli::CliCommand;
+use crate::errors::Result;
+use crate::lenra;
+
+use super::{loader, CommandContext};
+
+#[derive(clap::Args, Default, Debug, Clone)]
+pub struct Build {
+ /// Remove debug access to the app.
+ #[clap(long, alias = "prod", action)]
+ pub production: bool,
+}
+
+#[async_trait]
+impl CliCommand for Build {
+ async fn run(&self, context: &mut CommandContext) -> Result<()> {
+ generate_app_env_loader(context, self.production).await?;
+ build_loader(context).await
+ }
+}
+
+pub async fn generate_app_env_loader(context: &mut CommandContext, production: bool) -> Result<()> {
+ loader(
+ "Generate app env...",
+ "App env generated",
+ "Failed generating app env",
+ || async { lenra::generate_app_env(context, production).await },
+ )
+ .await
+}
+
+pub async fn build_loader(context: &mut CommandContext) -> Result<()> {
+ loader(
+ "Build app...",
+ "App built",
+ "Failed building app",
+ || async { lenra::build_app(context).await },
+ )
+ .await
+}
diff --git a/src/cli/check/mod.rs b/src/cli/check/mod.rs
new file mode 100644
index 0000000..c17c26d
--- /dev/null
+++ b/src/cli/check/mod.rs
@@ -0,0 +1,292 @@
+use std::fmt::Debug;
+
+use async_trait::async_trait;
+use clap::{Args, Subcommand};
+use colored::{Color, ColoredString, Colorize};
+use log::{debug, info};
+use serde_json::Value;
+
+use crate::{
+ docker_compose::{get_service_published_ports, Service},
+ errors::{Error, Result},
+ matching::{Matching, MatchingErrorType},
+};
+
+use self::template::TemplateChecker;
+
+use super::{CliCommand, CommandContext};
+
+mod template;
+
+pub const RULE_SEPARATOR: &str = ":";
+pub const VIEW: &str = "view";
+
+#[derive(Args, Clone, Debug)]
+pub struct Check {
+ #[clap(subcommand)]
+ command: CheckCommandType,
+}
+
+/// The check subcommands
+#[derive(Subcommand, Clone, Debug)]
+pub enum CheckCommandType {
+ // /// Checks the current project as an app
+ // App(CheckParameters),
+ /// Checks the current project as a template
+ Template(CheckParameters),
+}
+
+#[async_trait]
+impl CliCommand for Check {
+ async fn run(&self, context: &mut CommandContext) -> Result<()> {
+ // check that the app service is exposed
+ if get_service_published_ports(context, Service::App)
+ .await?
+ .is_empty()
+ {
+ return Err(Error::ServiceNotExposed(Service::App));
+ }
+ match self.command.clone() {
+ CheckCommandType::Template(params) => {
+ let template_checker = TemplateChecker;
+ template_checker.check(params)
+ }
+ }
+ }
+}
+
+#[derive(Args, Default, Clone, Debug)]
+pub struct CheckParameters {
+ /// The strict mode also fails with warning rules.
+ #[clap(long, action)]
+ pub strict: bool,
+
+ /// A list of rules to ignore
+ #[clap(long)]
+ pub ignore: Option>,
+
+ /// The rules
+ #[clap()]
+ pub rules: Vec,
+}
+
+pub trait AppChecker: Debug {
+ fn check_list(&self) -> Vec;
+
+ fn check(&self, params: CheckParameters) -> Result<()> {
+ info!("Check with {:?}", self);
+ // TODO: start app
+
+ let check_list = self.check_list();
+
+ debug!("Check list: {:?}", check_list);
+
+ let mut fail: bool = false;
+ check_list
+ .iter()
+ .filter(|checker| params.rules.is_empty() || params.rules.contains(&checker.name))
+ .for_each(|checker| {
+ let errors = checker.check(params.ignore.clone().unwrap_or(vec![]));
+ let name = checker.name.clone();
+ let mut messages: Vec = vec![];
+
+ let mut levels: Vec = errors
+ .iter()
+ .map(|error| {
+ let lvl = match error.level {
+ RuleErrorLevel::Warning => CheckerLevel::Warning,
+ RuleErrorLevel::Error => CheckerLevel::Error,
+ };
+ messages.push(
+ format!(" {}\n {}", error.rule, error.message)
+ .color(lvl.color()),
+ );
+ lvl
+ })
+ .collect();
+ levels.sort();
+ levels.reverse();
+
+ let level: &CheckerLevel = levels.get(0).unwrap_or(&CheckerLevel::Ok);
+ println!(
+ "{}",
+ format!("{:20}: {:?}", name, level).color(level.color())
+ );
+ messages.iter().for_each(|msg| println!("{}", msg));
+ if level == &CheckerLevel::Error
+ || (level == &CheckerLevel::Warning && params.strict)
+ {
+ fail = true;
+ }
+ });
+ if fail {
+ return Err(Error::Check);
+ }
+ Ok(())
+ }
+}
+
+fn ignore_rule(parts: Vec, ignores: Vec) -> bool {
+ let mut prefix = String::new();
+ for part in parts {
+ prefix.push_str(part.as_str());
+ if ignores.contains(&prefix) {
+ return true;
+ }
+ prefix.push_str(RULE_SEPARATOR);
+ if ignores.contains(&format!("{}*", prefix)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum CheckerLevel {
+ Ok,
+ Warning,
+ Error,
+}
+
+impl CheckerLevel {
+ fn color(&self) -> Color {
+ match self {
+ CheckerLevel::Ok => Color::Green,
+ CheckerLevel::Warning => Color::Yellow,
+ CheckerLevel::Error => Color::Red,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct ValueChecker {
+ name: String,
+ expected: Value,
+ loader: fn() -> Result,
+}
+
+impl ValueChecker {
+ pub fn rules(&self) -> Vec> {
+ vec![Rule {
+ name: "match".into(),
+ description: "Checks that the data matches the expected one".into(),
+ examples: vec![],
+ check: |value, expected| {
+ value
+ .check_match(&expected)
+ .iter()
+ .map(|err| match err.error_type.clone() {
+ MatchingErrorType::NotSameType { actual, expected } => RuleError {
+ rule: format!("{}{}{}", "sameType", RULE_SEPARATOR, err.path),
+ message: format!(
+ "Not matching type for {}: got {} but expected {}",
+ err.path,
+ actual.type_name(),
+ expected.type_name()
+ ),
+ level: RuleErrorLevel::Error,
+ },
+ MatchingErrorType::NotSameValue { actual, expected } => RuleError {
+ rule: format!("{}{}{}", "sameValue", RULE_SEPARATOR, err.path),
+ message: format!(
+ "Not matching value for {}: got {:?} but expected {:?}",
+ err.path, actual, expected
+ ),
+ level: RuleErrorLevel::Error,
+ },
+ MatchingErrorType::AdditionalProperty => RuleError {
+ rule: format!("{}{}{}", "additionalProperty", RULE_SEPARATOR, err.path),
+ message: format!("Additional property {}", err.path),
+ level: RuleErrorLevel::Warning,
+ },
+ MatchingErrorType::MissingProperty => RuleError {
+ rule: format!("{}{}{}", "missingProperty", RULE_SEPARATOR, err.path),
+ message: format!("Missing property {}", err.path),
+ level: RuleErrorLevel::Error,
+ },
+ })
+ .collect()
+ },
+ }]
+ }
+
+ pub fn check(&self, ignores: Vec) -> Vec {
+ if ignore_rule(vec![self.name.clone()], ignores.clone()) {
+ info!("Checker '{}' ignored", self.name);
+ return vec![];
+ }
+ let res = (self.loader)();
+ match res {
+ Ok(value) => self
+ .rules()
+ .iter()
+ .flat_map(|rule| {
+ if ignore_rule(vec![self.name.clone(), rule.name.clone()], ignores.clone()) {
+ info!("Rule '{}' ignored for checker '{}'", rule.name, self.name);
+ return vec![];
+ }
+
+ debug!("Check {}{}{}", self.name, RULE_SEPARATOR, rule.name);
+ rule.check(value.clone(), self.expected.clone())
+ .iter()
+ .map(|error| RuleError {
+ rule: format!("{}{}{}", self.name, RULE_SEPARATOR, error.rule),
+ message: error.message.clone(),
+ level: error.level.clone(),
+ })
+ .filter(|error| {
+ !ignore_rule(
+ error
+ .rule
+ .split(RULE_SEPARATOR)
+ .map(|str| str.into())
+ .collect(),
+ ignores.clone(),
+ )
+ })
+ .collect()
+ })
+ .collect(),
+ Err(err) => vec![RuleError {
+ rule: format!("{}{}{}", self.name, RULE_SEPARATOR, "unexpectedError"),
+ message: format!("Error loading {} checker data: {:?}", self.name, err),
+ level: RuleErrorLevel::Error,
+ }],
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct RuleError {
+ rule: String,
+ message: String,
+ level: RuleErrorLevel,
+}
+
+#[derive(Debug, Clone)]
+pub enum RuleErrorLevel {
+ Warning,
+ Error,
+}
+
+#[derive(Debug, Clone)]
+pub struct Rule {
+ pub name: String,
+ pub description: String,
+ pub examples: Vec,
+ pub check: fn(T, T) -> Vec,
+}
+
+impl Rule {
+ fn check(&self, param: T, expected: T) -> Vec {
+ (self.check)(param, expected)
+ }
+}
+
+pub fn call_app(request: Value) -> Result {
+ ureq::post("http://localhost:8080")
+ .send_json(request)
+ .map_err(Error::from)?
+ .into_json()
+ .map_err(Error::from)
+}
diff --git a/src/cli/check/template.rs b/src/cli/check/template.rs
new file mode 100644
index 0000000..91409c2
--- /dev/null
+++ b/src/cli/check/template.rs
@@ -0,0 +1,234 @@
+pub use clap::Args;
+
+use serde_json::json;
+
+use crate::errors::Error;
+
+use super::{call_app, AppChecker, ValueChecker, RULE_SEPARATOR, VIEW};
+
+#[derive(Debug)]
+pub struct TemplateChecker;
+
+impl AppChecker for TemplateChecker {
+ fn check_list(&self) -> Vec {
+ vec![
+ ValueChecker {
+ name: "manifest".into(),
+ loader: || call_app(json!({})).map_err(Error::from),
+ expected: json!({
+ "manifest": {
+ "rootView": "main"
+ }
+ }),
+ },
+ ValueChecker {
+ name: format!("{}{}{}", VIEW, RULE_SEPARATOR, "main"),
+ loader: || {
+ call_app(json!({
+ "view": "main"
+ }))
+ .map_err(Error::from)
+ },
+ expected: json!({
+ "type": "flex",
+ "direction": "vertical",
+ "scroll": true,
+ "spacing": 4,
+ "crossAxisAlignment": "center",
+ "children": [
+ {
+ "type": "view",
+ "name": "menu",
+ },
+ {
+ "type": "view",
+ "name": "home"
+ }
+ ]
+ }),
+ },
+ ValueChecker {
+ name: format!("{}{}{}", VIEW, RULE_SEPARATOR, "menu"),
+ loader: || {
+ call_app(json!({
+ "view": "menu"
+ }))
+ .map_err(Error::from)
+ },
+ expected: json!({
+ "type": "container",
+ "decoration": {
+ "color": 0xFFFFFFFFu32,
+ "boxShadow": {
+ "blurRadius": 8,
+ "color": 0x1A000000,
+ "offset": {
+ "dx": 0,
+ "dy": 1
+ }
+ },
+ },
+ "padding": {
+ "top": 16,
+ "bottom": 16,
+ "left": 32,
+ "right": 32,
+ },
+ "child": {
+ "type": "flex",
+ "fillParent": true,
+ "mainAxisAlignment": "spaceBetween",
+ "crossAxisAlignment": "center",
+ "padding": { "right": 32 },
+ "children": [
+ {
+ "type": "container",
+ "constraints": {
+ "minWidth": 32,
+ "minHeight": 32,
+ "maxWidth": 32,
+ "maxHeight": 32,
+ },
+ "child": {
+ "type": "image",
+ "src": "logo.png"
+ },
+ },
+ {
+ "type": "flexible",
+ "child": {
+ "type": "container",
+ "child": {
+ "type": "text",
+ "value": "Hello World",
+ "textAlign": "center",
+ "style": {
+ "fontWeight": "bold",
+ "fontSize": 24,
+ },
+ }
+ }
+ }
+ ]
+ },
+ }),
+ },
+ ValueChecker {
+ name: format!("{}{}{}", VIEW, RULE_SEPARATOR, "home"),
+ loader: || {
+ call_app(json!({
+ "view": "home"
+ }))
+ .map_err(Error::from)
+ },
+ expected: json!({
+ "type": "flex",
+ "direction": "vertical",
+ "spacing": 16,
+ "mainAxisAlignment": "spaceEvenly",
+ "crossAxisAlignment": "center",
+ "children": [
+ {
+ "type": "view",
+ "name": "counter",
+ "coll": "counter",
+ "query": {
+ "user": "@me"
+ },
+ "props": { "text": "My personnal counter" }
+ },
+ {
+ "type": "view",
+ "name": "counter",
+ "coll": "counter",
+ "query": {
+ "user": "global"
+ },
+ "props": { "text": "The common counter" }
+ }
+ ]
+ }),
+ },
+ ValueChecker {
+ name: format!("{}{}{}", VIEW, RULE_SEPARATOR, "counter"),
+ loader: || {
+ call_app(json!({
+ "view": "counter",
+ "data": [{
+ "_id": "ObjectId(my_counter_id)",
+ "count": 2,
+ "user": "my_user_id",
+ }],
+ "props": { "text": "My counter text" }
+ }))
+ .map_err(Error::from)
+ },
+ expected: json!({
+ "type": "flex",
+ "spacing": 16,
+ "mainAxisAlignment": "spaceEvenly",
+ "crossAxisAlignment": "center",
+ "children": [
+ {
+ "type": "text",
+ "value": "My counter text: 2",
+ },
+ {
+ "type": "button",
+ "text": "+",
+ "onPressed": {
+ "action": "increment",
+ "props": {
+ "id": "ObjectId(my_counter_id)"
+ }
+ }
+ }
+ ]
+ }),
+ },
+ ]
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::TemplateChecker;
+ use crate::cli::check::AppChecker;
+
+ #[test]
+ fn check_list_size() {
+ let template_checker = TemplateChecker;
+ let check_list = template_checker.check_list();
+ assert_eq!(
+ check_list.len(),
+ 5,
+ "The template checklist size is not correct"
+ );
+ }
+
+ #[test]
+ fn check_unique_names() {
+ let template_checker = TemplateChecker;
+ let check_list = template_checker.check_list();
+ let mut checker_names: Vec = vec![];
+ check_list.iter().for_each(|checker| {
+ assert!(
+ !checker_names.contains(&checker.name),
+ "There is at least two checkers with the same name: {}",
+ checker.name
+ );
+ checker_names.push(checker.name.clone());
+ let mut rule_names: Vec = vec![];
+
+ checker.rules().iter().for_each(|rule| {
+ assert!(
+ !rule_names.contains(&rule.name),
+ "There is at least two rules with the same name in the '{}' checker: {}",
+ checker.name,
+ rule.name
+ );
+ rule_names.push(rule.name.clone());
+ });
+ });
+ }
+}
diff --git a/src/cli/dev/interactive.rs b/src/cli/dev/interactive.rs
new file mode 100644
index 0000000..5a96a75
--- /dev/null
+++ b/src/cli/dev/interactive.rs
@@ -0,0 +1,133 @@
+use crate::{
+ cli::{
+ reload::Reload,
+ stop::Stop,
+ terminal::{TerminalCli, TerminalCommand},
+ },
+ errors::Result,
+ keyboard_event::{keyevent_to_string, KeyEventListener, KeyboardListener},
+};
+use clap::CommandFactory;
+pub use clap::{Args, Parser, Subcommand};
+use colored::{Color, Colorize};
+use lazy_static::__Deref;
+use log::debug;
+use rustyline::{Cmd, KeyCode, KeyEvent, Modifiers, Movement};
+use std::sync::{Arc, Mutex};
+use strum::{Display, EnumIter, IntoEnumIterator};
+
+const ENTER_EVENT: KeyEvent = KeyEvent(KeyCode::Enter, Modifiers::NONE);
+
+pub trait KeyboardShorcut {
+ fn about(&self) -> String;
+ fn events(&self) -> Vec;
+ fn to_value(&self) -> Option;
+}
+
+#[derive(EnumIter, Display, Debug, PartialEq, Clone)]
+pub enum InteractiveCommand {
+ Help,
+ Reload,
+ Quit,
+ Stop,
+}
+
+impl InteractiveCommand {
+ pub fn name(&self) -> String {
+ format!("{}", self)
+ }
+}
+
+impl KeyboardShorcut for InteractiveCommand {
+ fn about(&self) -> String {
+ match self {
+ InteractiveCommand::Help => "Print this message".into(),
+ InteractiveCommand::Quit => "Quit the interactive mode".into(),
+ _ => {
+ let main_command = TerminalCli::command();
+ let command = main_command.find_subcommand(self.name().to_lowercase().as_str());
+ command.unwrap().get_about().unwrap().into()
+ }
+ }
+ }
+
+ fn events(&self) -> Vec {
+ match self {
+ InteractiveCommand::Quit => vec![
+ KeyEvent(KeyCode::Char('q'), Modifiers::NONE),
+ KeyEvent(KeyCode::Char('C'), Modifiers::CTRL),
+ ],
+ _ => {
+ let name = format!("{}", self);
+ vec![KeyEvent::new(
+ name.to_lowercase().chars().next().unwrap(),
+ Modifiers::NONE,
+ )]
+ }
+ }
+ }
+
+ fn to_value(&self) -> Option {
+ match self {
+ InteractiveCommand::Help => {
+ display_help();
+ None
+ }
+ InteractiveCommand::Reload => Some(TerminalCommand::Reload(Reload {
+ ..Default::default()
+ })),
+ InteractiveCommand::Quit => Some(TerminalCommand::Exit),
+ InteractiveCommand::Stop => Some(TerminalCommand::Stop(Stop)),
+ }
+ }
+}
+
+pub async fn listen_interactive_command() -> Result> {
+ debug!("Listen interactive command");
+ let command: Arc>> = Arc::new(Mutex::new(None));
+ let mut listener = KeyboardListener::new()?;
+ InteractiveCommand::iter().for_each(|cmd| {
+ cmd.events().iter().for_each(|&event| {
+ let cmd = cmd.clone();
+ let local_command = command.clone();
+ let f = move || {
+ let mut c = local_command.lock().unwrap();
+ *c = cmd.to_value();
+ debug!("{}", cmd.name());
+ Some(Cmd::AcceptLine)
+ };
+ listener.add_listener(event, f);
+ });
+ });
+ listener.add_listener(ENTER_EVENT, || {
+ println!();
+ Some(Cmd::Replace(Movement::WholeBuffer, Some("".into())))
+ });
+ listener.listen().await?;
+ let mutex = command.lock().unwrap();
+ let command = mutex.deref();
+ debug!("Interactive command: {:?}", command);
+ Ok(command.clone())
+}
+
+fn display_help() {
+ let mut vector = Vec::new();
+ vector.extend(InteractiveCommand::iter().map(|cmd| {
+ let mut shortcuts = Vec::new();
+ shortcuts.extend(cmd.events().iter().map(|&e| keyevent_to_string(e)));
+ format!(
+ " {:8} {:15} {}",
+ cmd.name().color(Color::Green),
+ shortcuts.join(", ").color(Color::Blue),
+ cmd.about()
+ )
+ }));
+ println!(
+ "\n{} ({} {} {})\n{}\n",
+ "SHORTCUTS:".color(Color::Yellow),
+ "Command".color(Color::Green),
+ "Key(s)".color(Color::Blue),
+ "Description",
+ vector.join("\n")
+ )
+}
diff --git a/src/cli/dev/mod.rs b/src/cli/dev/mod.rs
new file mode 100644
index 0000000..c89caac
--- /dev/null
+++ b/src/cli/dev/mod.rs
@@ -0,0 +1,87 @@
+use async_trait::async_trait;
+use chrono::{DateTime, SecondsFormat, Utc};
+pub use clap::Args;
+use tokio::select;
+
+use crate::docker_compose::Service;
+use crate::errors::Result;
+use crate::{
+ cli::{
+ build,
+ dev::interactive::listen_interactive_command,
+ logs::Logs,
+ start,
+ terminal::{run_command, TerminalCommand},
+ CliCommand, CommandContext,
+ },
+ lenra,
+};
+
+use interactive::{InteractiveCommand, KeyboardShorcut};
+
+mod interactive;
+
+#[derive(Args, Debug, Clone)]
+pub struct Dev {
+ /// Attach the dev mode without rebuilding the app and restarting it.
+ #[clap(long, action)]
+ pub attach: bool,
+}
+
+#[async_trait]
+impl CliCommand for Dev {
+ async fn run(&self, context: &mut CommandContext) -> Result<()> {
+ log::info!("Run dev mode");
+
+ if !self.attach {
+ build::generate_app_env_loader(context, false).await?;
+ build::build_loader(context).await?;
+ start::start_loader(context).await?;
+ start::clear_cache_loader(context).await?;
+ }
+
+ let previous_log = Logs {
+ services: vec![Service::App],
+ follow: true,
+ ..Default::default()
+ };
+ let mut last_logs: Option> = None;
+
+ lenra::display_app_access_url();
+ InteractiveCommand::Help.to_value();
+ let mut interactive_cmd = None;
+ loop {
+ if let Some(command) = interactive_cmd {
+ let keep_running = run_command(&command, context).await;
+ if !keep_running {
+ break;
+ }
+ }
+ let end_date;
+ (end_date, interactive_cmd) = run_logs(&previous_log, last_logs, context).await?;
+ last_logs = Some(end_date);
+ }
+
+ log::debug!("End of dev mode");
+ Ok(())
+ }
+}
+
+async fn run_logs(
+ logs: &Logs,
+ last_end: Option>,
+ context: &mut CommandContext,
+) -> Result<(DateTime, Option)> {
+ let mut clone = logs.clone();
+ if let Some(last_logs) = last_end {
+ // Only displays new logs
+ clone.since = Some(last_logs.to_rfc3339_opts(SecondsFormat::Secs, true));
+ }
+
+ let command = select! {
+ res = listen_interactive_command() => {res?}
+ res = clone.run(context) => {res?; None}
+ // res = tokio::signal::ctrl_c() => {res?; None}
+ };
+ Ok((Utc::now(), command))
+}
diff --git a/src/cli/logs.rs b/src/cli/logs.rs
new file mode 100644
index 0000000..36a9776
--- /dev/null
+++ b/src/cli/logs.rs
@@ -0,0 +1,96 @@
+use std::process::Stdio;
+
+use async_trait::async_trait;
+pub use clap::Args;
+use log::warn;
+
+use crate::cli::CliCommand;
+use crate::docker_compose::{create_compose_command, Service};
+use crate::errors::Result;
+
+use super::CommandContext;
+
+#[derive(Args, Default, Clone, Debug)]
+pub struct Logs {
+ /// Follow log output
+ #[clap(short, long, action)]
+ pub follow: bool,
+
+ /// Produce monochrome output
+ #[clap(long, action)]
+ pub no_color: bool,
+
+ /// Don't print prefix in logs
+ #[clap(long, action)]
+ pub no_log_prefix: bool,
+
+ /// Show logs since timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)
+ #[clap(long)]
+ pub since: Option,
+
+ /// Number of lines to show from the end of the logs for each container
+ #[clap(long, default_value = "all")]
+ pub tail: String,
+
+ /// Show timestamps
+ #[clap(short, long, action)]
+ pub timestamps: bool,
+
+ /// Show logs before a timestamp (e.g. 2013-01-02T13:23:37Z) or relative (e.g. 42m for 42 minutes)
+ #[clap(long)]
+ pub until: Option,
+
+ /// The logged service list
+ #[clap(value_enum, default_value = "app")]
+ pub services: Vec,
+}
+
+#[async_trait]
+impl CliCommand for Logs {
+ async fn run(&self, context: &mut CommandContext) -> Result<()> {
+ log::info!("Show logs");
+
+ let mut command = create_compose_command(context);
+
+ command
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .arg("logs")
+ .arg("--tail")
+ .arg(self.tail.clone());
+
+ if self.follow {
+ command.arg("--follow");
+ }
+ if self.no_color {
+ command.arg("--no-color");
+ }
+ if self.no_log_prefix {
+ command.arg("--no-log-prefix");
+ }
+ if let Some(since) = self.since.clone() {
+ command.arg("--since").arg(since);
+ }
+ if self.timestamps {
+ command.arg("--timestamps");
+ }
+ if let Some(until) = self.until.clone() {
+ command.arg("--until").arg(until);
+ }
+ self.services.iter().for_each(|service| {
+ command.arg(service.to_str());
+ });
+
+ log::debug!("cmd: {:?}", command);
+ let output = command.spawn()?.wait_with_output().await?;
+ if !output.status.success() {
+ warn!(
+ "An error occured while displaying the docker-compose logs:\n{}\n{}",
+ String::from_utf8(output.stdout).unwrap(),
+ String::from_utf8(output.stderr).unwrap()
+ )
+ }
+
+ Ok(())
+ }
+}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
new file mode 100644
index 0000000..0be2e53
--- /dev/null
+++ b/src/cli/mod.rs
@@ -0,0 +1,323 @@
+use std::{future::Future, path::PathBuf};
+
+use async_trait::async_trait;
+pub use clap::{Args, Parser, Subcommand};
+use loading::Loading;
+use log::debug;
+
+use crate::{
+ config::{load_config_file, Application, DEFAULT_CONFIG_FILE},
+ docker_compose::Service,
+ errors::Result,
+};
+
+use self::{
+ build::Build, check::Check, dev::Dev, logs::Logs, new::New, reload::Reload, start::Start,
+ stop::Stop, update::Update, upgrade::Upgrade,
+};
+
+mod build;
+mod check;
+mod dev;
+mod logs;
+mod new;
+mod reload;
+mod start;
+mod stop;
+pub mod terminal;
+mod update;
+mod upgrade;
+
+/// The Lenra command line interface
+#[derive(Parser, Debug, Clone)]
+#[clap(author, version, about, long_about = None, rename_all = "kebab-case")]
+pub struct Cli {
+ #[clap(subcommand)]
+ pub command: Option,
+
+ /// The app configuration file.
+ #[clap(global=true, parse(from_os_str), long, default_value = DEFAULT_CONFIG_FILE)]
+ pub config: std::path::PathBuf,
+
+ /// Exposes services ports.
+ #[clap(global=true, long, value_enum, default_values = &[], default_missing_values = &["app", "postgres", "mongo"])]
+ pub expose: Vec,
+
+ /// Run the commands as verbose.
+ #[clap(global = true, short, long, action)]
+ pub verbose: bool,
+}
+
+#[async_trait]
+pub trait CliCommand {
+ async fn run(&self, context: &mut CommandContext) -> Result<()>;
+ fn need_config(&self) -> bool {
+ true
+ }
+}
+
+/// The subcommands
+#[derive(Subcommand, Debug, Clone)]
+pub enum Command {
+ /// Create a new Lenra app project from a template
+ New(New),
+ /// Build your app in release mode
+ Build(Build),
+ /// Start your app previously built with the build command
+ Start(Start),
+ /// View output from the containers
+ Logs(Logs),
+ /// Stop your app previously started with the start command
+ Stop(Stop),
+ /// Start the app in an interactive mode
+ Dev(Dev),
+ /// Upgrade the app with the last template updates
+ Upgrade(Upgrade),
+ /// Update the tools Docker images
+ Update(Update),
+ /// Checks the running app
+ Check(Check),
+ /// Reload the app by rebuilding and restarting it
+ Reload(Reload),
+}
+
+#[async_trait]
+impl CliCommand for Command {
+ async fn run(&self, context: &mut CommandContext) -> Result<()> {
+ log::debug!("Run command {:?}", self);
+ if self.need_config() {
+ context.load_config()?;
+ }
+ match self {
+ Command::New(new) => new.run(context),
+ Command::Build(build) => build.run(context),
+ Command::Start(start) => start.run(context),
+ Command::Logs(logs) => logs.run(context),
+ Command::Stop(stop) => stop.run(context),
+ Command::Dev(dev) => dev.run(context),
+ Command::Upgrade(upgrade) => upgrade.run(context),
+ Command::Update(update) => update.run(context),
+ Command::Check(check) => check.run(context),
+ Command::Reload(reload) => reload.run(context),
+ }
+ .await
+ }
+
+ fn need_config(&self) -> bool {
+ match self {
+ Command::New(_) => false,
+ _ => true,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct CommandContext {
+ /// The app configuration file.
+ pub config_path: std::path::PathBuf,
+
+ /// The app configuration.
+ pub config: Option,
+
+ /// Exposes all services ports.
+ pub expose: Vec,
+
+ /// Run command as verbose.
+ pub verbose: bool,
+}
+
+impl CommandContext {
+ pub fn load_config(&mut self) -> Result {
+ debug!("Load config from {:?}", self.config_path);
+ let app: Application = load_config_file(&self.config_path)?;
+ self.config = Some(app.clone());
+ Ok(app)
+ }
+
+ /// Resolve a path relative to the current directory and base on the path property of the config.
+ pub fn resolve_path(&self, path: &PathBuf) -> PathBuf {
+ let mut resolved_path = self.get_app_workdir();
+ resolved_path.push(path);
+ debug!("Resolved path {:?} to {:?}", path, resolved_path);
+ resolved_path
+ }
+
+ pub fn get_app_workdir(&self) -> PathBuf {
+ let mut workdir = self.config_path.clone();
+ workdir.pop();
+ if let Some(app_dir_path) = self.get_app_path_config() {
+ workdir.push(app_dir_path);
+ };
+ workdir
+ }
+
+ fn get_app_path_config(&self) -> Option {
+ self.config
+ .as_ref()
+ .map(|app| app.path.clone().unwrap_or(PathBuf::from(".")))
+ }
+}
+
+pub async fn loader(text: &str, success: &str, fail: &str, task: F) -> Result
+where
+ F: FnOnce() -> Fut,
+ Fut: Future>,
+{
+ let loading = Loading::default();
+ loading.text(text);
+ let res = task().await;
+ if res.is_ok() {
+ loading.success(success);
+ } else {
+ loading.fail(fail);
+ }
+ loading.end();
+ res
+}
+
+#[cfg(test)]
+mod test {
+ use std::path::PathBuf;
+
+ use clap::{CommandFactory, FromArgMatches};
+
+ use super::*;
+
+ pub fn parse_command_line(line: String) -> Result {
+ let args = &mut line.split_whitespace().collect::>();
+ let command = ::command();
+ let mut matches = command
+ .clone()
+ .try_get_matches_from(args.clone())
+ .map_err(format_error)?;
+ ::from_arg_matches_mut(&mut matches).map_err(format_error)
+ }
+
+ fn format_error(err: clap::Error) -> clap::Error {
+ let mut command = ::command();
+ err.format(&mut command)
+ }
+
+ #[test]
+ fn test_load_config() {
+ let mut context = CommandContext::default();
+ context.config_path = PathBuf::from("test/config/app_path.yml");
+ let app = context.load_config().unwrap();
+ assert_eq!(app.path, Some(PathBuf::from("test_app")));
+ }
+
+ macro_rules! resolve_path_tests {
+ ($($name:ident: $value:expr,)*) => {
+ mod resolve_path {
+ use std::path::PathBuf;
+ use super::super::*;
+ $(
+ #[test]
+ fn $name() {
+ let (config_path, config, expected) = $value;
+ let mut context = CommandContext::default();
+ context.config_path = PathBuf::from(config_path);
+ context.config = config;
+ let path = PathBuf::from(".lenra/compose.yml");
+ let resolved_path = context.resolve_path(&path);
+ assert_eq!(
+ resolved_path,
+ PathBuf::from(expected)
+ );
+ }
+ )*
+ }
+ }
+ }
+
+ resolve_path_tests! {
+ simple: (
+ "",
+ Some(Application {..Default::default()}),
+ "./.lenra/compose.yml"
+ ),
+ app_path: (
+ "",
+ Some(Application {path: Some(PathBuf::from("test_app")), ..Default::default()}),
+ "test_app/.lenra/compose.yml"
+ ),
+ app_path_and_config_file: (
+ "test/config/lenra.yml",
+ Some(Application {path: Some(PathBuf::from("test_app")), ..Default::default()}),
+ "test/config/test_app/.lenra/compose.yml"
+ ),
+ app_path_in_parent_dir: (
+ "",
+ Some(Application {path: Some(PathBuf::from("../test_app")), ..Default::default()}),
+ "../test_app/.lenra/compose.yml"
+ ),
+ app_path_in_parent_dir_and_config_file: (
+ "test/config/lenra.yml",
+ Some(Application {path: Some(PathBuf::from("../test_app")), ..Default::default()}),
+ "test/config/../test_app/.lenra/compose.yml"
+ ),
+ }
+
+ macro_rules! get_app_workdir_tests {
+ ($($name:ident: $value:expr,)*) => {
+ mod get_app_workdir {
+ use std::path::PathBuf;
+ use super::super::*;
+ $(
+ #[test]
+ fn $name() {
+ let (config_path, config, expected) = $value;
+ let mut context = CommandContext::default();
+ context.config_path = PathBuf::from(config_path);
+ context.config = config;
+ let workdir = context.get_app_workdir();
+ assert_eq!(
+ workdir,
+ PathBuf::from(expected)
+ );
+ }
+ )*
+ }
+ }
+ }
+
+ get_app_workdir_tests! {
+ simple: (
+ "",
+ Some(Application {..Default::default()}),
+ "."
+ ),
+ app_path: (
+ "",
+ Some(Application {path: Some(PathBuf::from("test_app")), ..Default::default()}),
+ "test_app"
+ ),
+ app_path_and_config_file: (
+ "test/config/lenra.yml",
+ Some(Application {path: Some(PathBuf::from("test_app")), ..Default::default()}),
+ "test/config/test_app"
+ ),
+ app_path_in_parent_dir: (
+ "",
+ Some(Application {path: Some(PathBuf::from("../test_app")), ..Default::default()}),
+ "../test_app"
+ ),
+ app_path_in_parent_dir_and_config_file: (
+ "test/config/lenra.yml",
+ Some(Application {path: Some(PathBuf::from("../test_app")), ..Default::default()}),
+ "test/config/../test_app"
+ ),
+ }
+
+ #[test]
+ fn test_get_app_path_config() {
+ let mut context = CommandContext::default();
+ let app = Application {
+ path: Some(PathBuf::from("test_app")),
+ ..Default::default()
+ };
+ context.config = Some(app);
+ let app_path = context.get_app_path_config().unwrap();
+ assert_eq!(app_path, PathBuf::from("test_app"));
+ }
+}
diff --git a/src/cli/new.rs b/src/cli/new.rs
new file mode 100644
index 0000000..d30eb30
--- /dev/null
+++ b/src/cli/new.rs
@@ -0,0 +1,286 @@
+//! # new
+//!
+//! The new subcommand creates a new Lenra app project from a template
+
+use async_trait::async_trait;
+pub use clap::Args;
+
+use crate::cli::CliCommand;
+use crate::errors::{Error, Result};
+use crate::{cli, git, lenra, template};
+
+use super::CommandContext;
+
+#[derive(Args, Debug, Clone)]
+pub struct New {
+ /// The project template topics from which your project will be created.
+ /// For example, defining `rust` look for the next API endpoint: https://api.github.com/search/repositories?q=topic:lenra+topic:template+topic:rust&sort=stargazers
+ /// You can find all the templates at this url: https://github.com/search?q=topic%3Alenra+topic%3Atemplate&sort=stargazers&type=repositories
+ /// You also can set the template project full url to use custom ones.
+ pub topics: Vec,
+
+ /// The new project path
+ #[clap(short, long, parse(from_os_str), default_value = ".")]
+ path: std::path::PathBuf,
+}
+
+#[async_trait]
+impl CliCommand for New {
+ async fn run(&self, _context: &mut CommandContext) -> Result<()> {
+ log::debug!("topics {:?}", self.topics);
+
+ let template =
+ if self.topics.len() == 1 && git::GIT_REPO_REGEX.is_match(self.topics[0].as_str()) {
+ self.topics[0].clone()
+ } else {
+ let repos = template::list_templates(&self.topics).await?;
+ if repos.is_empty() {
+ return Err(Error::NoTemplateFound);
+ } else if repos.len() == 1 {
+ repos[0].url.clone()
+ } else {
+ template::choose_repository(repos).await?.url
+ }
+ };
+
+ println!("Using template: {}", template);
+ cli::loader(
+ "Creating new project...",
+ "Project created",
+ "Failed creating new project",
+ || async { lenra::create_new_project(template.as_str(), &self.path).await },
+ )
+ .await
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::{rc::Rc, sync::Mutex};
+
+ use mocktopus::mocking::{MockResult, Mockable};
+
+ use super::*;
+ use crate::{
+ cli::{self, Command},
+ git::Repository,
+ template,
+ };
+
+ const NODE_TEMPLATE_HTTP_URL: &str = "https://github.com/lenra-io/template-javascript.git";
+ const NODE_TEMPLATE_SSH_URL: &str = "git@github.com:lenra-io/template-javascript.git";
+ const BUN_TEMPLATE_HTTP_URL: &str = "https://github.com/taorepoara/lenra-template-bun-js.git";
+
+ fn mock_all() {
+ template::list_templates.mock_safe(|_| unreachable!());
+ template::choose_repository.mock_safe(|_| unreachable!());
+ lenra::create_new_project.mock_safe(|_, _| unreachable!());
+ }
+
+ #[tokio::test]
+ async fn no_matching_template() -> Result<(), Box> {
+ let cli = cli::test::parse_command_line(String::from("lenra new js"))?;
+ let command = cli.command;
+ let new = match command {
+ Some(Command::New(new)) => new,
+ _ => panic!("wrong command"),
+ };
+ let expected_topics = vec!["js".to_string()];
+
+ assert_eq!(new.path, std::path::PathBuf::from("."));
+ assert_eq!(new.topics, expected_topics);
+ mock_all();
+ let list_templates_call_counter = Rc::new(Mutex::new(0));
+ let counter = Rc::clone(&list_templates_call_counter);
+ template::list_templates.mock_safe(move |topics| {
+ let mut num = counter.lock().unwrap();
+ *num += 1;
+ println!("called {} times", *num);
+ assert_eq!(topics, &expected_topics);
+ MockResult::Return(Box::pin(async move { Ok(vec![]) }))
+ });
+ let result = new
+ .run(&mut CommandContext {
+ ..Default::default()
+ })
+ .await;
+ let call_count = *list_templates_call_counter.lock().unwrap();
+ println!("called {} times", call_count);
+ assert_eq!(call_count, 1);
+ assert!(result.is_err());
+ let error = result.unwrap_err();
+ match error {
+ Error::NoTemplateFound => (),
+ er => panic!("wrong error type {er}"),
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn one_matching_template() -> Result<(), Box> {
+ let cli = cli::test::parse_command_line(String::from("lenra new js"))?;
+ let command = cli.command;
+ let new = match command {
+ Some(Command::New(new)) => new,
+ _ => panic!("wrong command"),
+ };
+ let expected_topics = vec!["js".to_string()];
+
+ assert_eq!(new.path, std::path::PathBuf::from("."));
+ assert_eq!(new.topics, expected_topics);
+ mock_all();
+ let list_templates_call_counter = Rc::new(Mutex::new(0));
+ let counter = Rc::clone(&list_templates_call_counter);
+ template::list_templates.mock_safe(move |topics| {
+ let mut num = counter.lock().unwrap();
+ *num += 1;
+ assert_eq!(topics, &expected_topics);
+ MockResult::Return(Box::pin(async move {
+ Ok(vec![Repository {
+ name: "template-javascript".to_string(),
+ description: "Javascript template".to_string(),
+ url: NODE_TEMPLATE_HTTP_URL.into(),
+ stars: 0,
+ }])
+ }))
+ });
+ let create_new_project_call_counter = Rc::new(Mutex::new(0));
+ let counter = Rc::clone(&create_new_project_call_counter);
+ lenra::create_new_project.mock_safe(move |_, _| {
+ let mut num = counter.lock().unwrap();
+ *num += 1;
+ MockResult::Return(Box::pin(async move { Ok(()) }))
+ });
+ let result = new
+ .run(&mut CommandContext {
+ ..Default::default()
+ })
+ .await;
+ assert_eq!(*list_templates_call_counter.lock().unwrap(), 1);
+ assert_eq!(*create_new_project_call_counter.lock().unwrap(), 1);
+ assert!(result.is_ok());
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn two_matching_templates() -> Result<(), Box> {
+ let cli = cli::test::parse_command_line(String::from("lenra new js"))?;
+ let command = cli.command;
+ let new = match command {
+ Some(Command::New(new)) => new,
+ _ => panic!("wrong command"),
+ };
+ let expected_topics = vec!["js".to_string()];
+
+ assert_eq!(new.path, std::path::PathBuf::from("."));
+ assert_eq!(new.topics, expected_topics);
+ mock_all();
+ let list_templates_call_counter = Rc::new(Mutex::new(0));
+ let counter = Rc::clone(&list_templates_call_counter);
+ template::list_templates.mock_safe(move |topics| {
+ let mut num = counter.lock().unwrap();
+ *num += 1;
+ assert_eq!(topics, &expected_topics);
+ MockResult::Return(Box::pin(async move {
+ Ok(vec![
+ Repository {
+ name: "template-javascript".to_string(),
+ description: "Javascript template".to_string(),
+ url: NODE_TEMPLATE_HTTP_URL.into(),
+ stars: 1,
+ },
+ Repository {
+ name: "template-bun".to_string(),
+ description: "Javascript template with Bun.sh".to_string(),
+ url: BUN_TEMPLATE_HTTP_URL.into(),
+ stars: 0,
+ },
+ ])
+ }))
+ });
+ let choose_repository_call_counter = Rc::new(Mutex::new(0));
+ let counter = Rc::clone(&choose_repository_call_counter);
+ template::choose_repository.mock_safe(move |repos| {
+ let mut num = counter.lock().unwrap();
+ *num += 1;
+ assert_eq!(repos.len(), 2);
+ MockResult::Return(Box::pin(async move { Ok(repos[0].clone()) }))
+ });
+ let create_new_project_call_counter = Rc::new(Mutex::new(0));
+ let counter = Rc::clone(&create_new_project_call_counter);
+ lenra::create_new_project.mock_safe(move |_, _| {
+ let mut num = counter.lock().unwrap();
+ *num += 1;
+ MockResult::Return(Box::pin(async move { Ok(()) }))
+ });
+ let result = new
+ .run(&mut CommandContext {
+ ..Default::default()
+ })
+ .await;
+ assert_eq!(*list_templates_call_counter.lock().unwrap(), 1);
+ assert_eq!(*create_new_project_call_counter.lock().unwrap(), 1);
+ assert!(result.is_ok());
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn from_http_url() -> Result<(), Box> {
+ let cli = cli::test::parse_command_line(format!("lenra new {}", NODE_TEMPLATE_HTTP_URL))?;
+ let command = cli.command;
+ let new = match command {
+ Some(Command::New(new)) => new,
+ _ => panic!("wrong command"),
+ };
+ let expected_topics = vec![NODE_TEMPLATE_HTTP_URL.to_string()];
+
+ assert_eq!(new.path, std::path::PathBuf::from("."));
+ assert_eq!(new.topics, expected_topics);
+ mock_all();
+ let create_new_project_call_counter = Rc::new(Mutex::new(0));
+ let counter = Rc::clone(&create_new_project_call_counter);
+ lenra::create_new_project.mock_safe(move |_, _| {
+ let mut num = counter.lock().unwrap();
+ *num += 1;
+ MockResult::Return(Box::pin(async move { Ok(()) }))
+ });
+ let result = new
+ .run(&mut CommandContext {
+ ..Default::default()
+ })
+ .await;
+ assert_eq!(*create_new_project_call_counter.lock().unwrap(), 1);
+ assert!(result.is_ok());
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn from_ssh_url() -> Result<(), Box> {
+ let cli = cli::test::parse_command_line(format!("lenra new {}", NODE_TEMPLATE_SSH_URL))?;
+ let command = cli.command;
+ let new = match command {
+ Some(Command::New(new)) => new,
+ _ => panic!("wrong command"),
+ };
+ let expected_topics = vec![NODE_TEMPLATE_SSH_URL.to_string()];
+
+ assert_eq!(new.path, std::path::PathBuf::from("."));
+ assert_eq!(new.topics, expected_topics);
+ mock_all();
+ let create_new_project_call_counter = Rc::new(Mutex::new(0));
+ let counter = Rc::clone(&create_new_project_call_counter);
+ lenra::create_new_project.mock_safe(move |_, _| {
+ let mut num = counter.lock().unwrap();
+ *num += 1;
+ MockResult::Return(Box::pin(async move { Ok(()) }))
+ });
+ let result = new
+ .run(&mut CommandContext {
+ ..Default::default()
+ })
+ .await;
+ assert_eq!(*create_new_project_call_counter.lock().unwrap(), 1);
+ assert!(result.is_ok());
+ Ok(())
+ }
+}
diff --git a/src/cli/reload.rs b/src/cli/reload.rs
new file mode 100644
index 0000000..bf53d7e
--- /dev/null
+++ b/src/cli/reload.rs
@@ -0,0 +1,24 @@
+use async_trait::async_trait;
+pub use clap::Args;
+
+use crate::cli::CliCommand;
+use crate::errors::Result;
+
+use super::{
+ build::{build_loader, generate_app_env_loader},
+ start::{clear_cache_loader, start_loader},
+ CommandContext,
+};
+
+#[derive(Args, Default, Debug, Clone)]
+pub struct Reload;
+
+#[async_trait]
+impl CliCommand for Reload {
+ async fn run(&self, context: &mut CommandContext) -> Result<()> {
+ generate_app_env_loader(context, false).await?;
+ build_loader(context).await?;
+ start_loader(context).await?;
+ clear_cache_loader(context).await
+ }
+}
diff --git a/src/cli/start.rs b/src/cli/start.rs
new file mode 100644
index 0000000..df9cbf8
--- /dev/null
+++ b/src/cli/start.rs
@@ -0,0 +1,41 @@
+use async_trait::async_trait;
+pub use clap::Args;
+
+use crate::cli::CliCommand;
+use crate::errors::Result;
+use crate::lenra;
+
+use super::{loader, CommandContext};
+
+#[derive(Args, Default, Debug, Clone)]
+pub struct Start;
+
+#[async_trait]
+impl CliCommand for Start {
+ async fn run(&self, context: &mut CommandContext) -> Result<()> {
+ start_loader(context).await?;
+ clear_cache_loader(context).await?;
+ lenra::display_app_access_url();
+ Ok(())
+ }
+}
+
+pub async fn start_loader(context: &mut CommandContext) -> Result<()> {
+ loader(
+ "Start app environment...",
+ "App environment started",
+ "Failed starting app",
+ || async { lenra::start_env(context).await },
+ )
+ .await
+}
+
+pub async fn clear_cache_loader(context: &mut CommandContext) -> Result<()> {
+ loader(
+ "Clearing cache...",
+ "Cache cleared",
+ "Failed clearing cache",
+ || async { lenra::clear_cache(context).await },
+ )
+ .await
+}
diff --git a/src/cli/stop.rs b/src/cli/stop.rs
new file mode 100644
index 0000000..e690b9d
--- /dev/null
+++ b/src/cli/stop.rs
@@ -0,0 +1,28 @@
+use async_trait::async_trait;
+pub use clap::Args;
+
+use crate::cli::CliCommand;
+use crate::errors::Result;
+use crate::lenra;
+
+use super::{loader, CommandContext};
+
+#[derive(Args, Debug, Clone)]
+pub struct Stop;
+
+#[async_trait]
+impl CliCommand for Stop {
+ async fn run(&self, context: &mut CommandContext) -> Result<()> {
+ stop_loader(context).await
+ }
+}
+
+pub async fn stop_loader(context: &mut CommandContext) -> Result<()> {
+ loader(
+ "Stop app environment...",
+ "App environment stopped",
+ "Failed stopping app environment",
+ || async { lenra::stop_env(context).await },
+ )
+ .await
+}
diff --git a/src/cli/terminal.rs b/src/cli/terminal.rs
new file mode 100644
index 0000000..8d34d20
--- /dev/null
+++ b/src/cli/terminal.rs
@@ -0,0 +1,203 @@
+use std::fs;
+
+use crate::lenra;
+pub use clap::{Args, Parser, Subcommand};
+use clap::{CommandFactory, FromArgMatches};
+use colored::{Color, Colorize};
+use dirs::config_dir;
+use log::{debug, warn};
+use rustyline::{error::ReadlineError, Editor};
+
+use crate::{
+ docker_compose::Service,
+ errors::{Error, Result},
+};
+
+use crate::cli::{check::Check, logs::Logs, CliCommand};
+
+use super::{
+ build::Build, dev::Dev, reload::Reload, start::Start, stop::Stop, update::Update,
+ upgrade::Upgrade, CommandContext,
+};
+
+const LENRA_COMMAND: &str = "lenra";
+const READLINE_PROMPT: &str = "[lenra]$ ";
+// const ESCAPE_EVENT: KeyEvent = KeyEvent(KeyCode::Esc, Modifiers::NONE);
+
+pub async fn start_terminal(context: &mut CommandContext) -> Result<()> {
+ let history_path = config_dir()
+ .ok_or(Error::Custom("Can't get the user config directory".into()))?
+ .join("lenra")
+ .join("dev.history");
+ let mut rl = Editor::<()>::new()?;
+
+ debug!("Load history from {:?}", history_path);
+ if rl.load_history(&history_path).is_err() {
+ debug!("No previous history.");
+ }
+
+ loop {
+ let readline = rl.readline(READLINE_PROMPT);
+ let command = match readline {
+ Ok(line) => {
+ if line.trim().is_empty() {
+ continue;
+ }
+
+ rl.add_history_entry(line.as_str());
+
+ let parse_result = parse_command_line(line.clone()).map_err(Error::from);
+ match parse_result {
+ Ok(dev_cli) => dev_cli.command,
+ Err(Error::ParseCommand(clap_error)) => {
+ clap_error.print().ok();
+ continue;
+ }
+ Err(err) => {
+ debug!("Parse command error: {}", err);
+ warn!("not a valid command {}", line);
+ continue;
+ }
+ }
+ }
+ Err(ReadlineError::Interrupted) => {
+ debug!("CTRL-C");
+ break;
+ }
+ Err(ReadlineError::Eof) => {
+ debug!("CTRL-D");
+ break;
+ }
+ Err(err) => {
+ println!("Error: {:?}", err);
+ break;
+ }
+ };
+
+ debug!("Run command {:#?}", command);
+ let keep_running = run_command(&command, context).await;
+ if !keep_running {
+ break;
+ }
+ }
+ debug!("Save history to {:?}", history_path);
+ fs::create_dir_all(history_path.parent().unwrap())?;
+ rl.save_history(&history_path).map_err(Error::from)
+}
+
+pub async fn run_command(command: &TerminalCommand, context: &mut CommandContext) -> bool {
+ debug!("Run command {:#?}", command);
+ command.run(context).await.unwrap_or_else(|error| {
+ eprintln!("{}", error.to_string().color(Color::Red));
+ });
+ let keep_running = match command {
+ TerminalCommand::Exit | TerminalCommand::Stop(_) => false,
+ _ => true,
+ };
+ keep_running
+}
+
+fn parse_command_line(line: String) -> Result {
+ let args = &mut line.split_whitespace().collect::>();
+
+ let first_arg = if args.len() > 0 { Some(args[0]) } else { None };
+ if let Some(arg) = first_arg {
+ if LENRA_COMMAND != arg {
+ args.push(LENRA_COMMAND);
+ args.rotate_right(1);
+ }
+ }
+ debug!("Try to parse dev terminal command: {:?}", args);
+
+ let command = ::command();
+ let mut matches = command
+ .clone()
+ .try_get_matches_from(args.clone())
+ .map_err(format_error)?;
+ ::from_arg_matches_mut(&mut matches).map_err(format_error)
+}
+
+fn format_error(err: clap::Error) -> clap::Error {
+ let mut command = ::command();
+ err.format(&mut command)
+}
+
+/// The Lenra interactive command line interface
+#[derive(Parser, Debug, Clone)]
+#[clap(rename_all = "kebab-case")]
+pub struct TerminalCli {
+ #[clap(subcommand)]
+ pub command: TerminalCommand,
+}
+
+/// The interactive commands
+#[derive(Subcommand, Clone, Debug)]
+pub enum TerminalCommand {
+ /// Build your app in release mode
+ Build(Build),
+ /// Start your app previously built with the build command
+ Start(Start),
+ /// View output from the containers
+ Logs(Logs),
+ /// Stop your app previously started with the start command
+ Stop(Stop),
+ /// Start the app in an interactive mode
+ Dev(Dev),
+ /// Upgrade the app with the last template updates
+ Upgrade(Upgrade),
+ /// Update the tools Docker images
+ Update(Update),
+ /// Checks the running app
+ Check(Check),
+ /// Reload the app by rebuilding and restarting it
+ Reload(Reload),
+ /// Exits the terminal
+ Exit,
+ /// Exposes the app ports
+ Expose(Expose),
+}
+
+#[derive(Args, Clone, Debug)]
+pub struct Expose {
+ /// The service list to expose
+ #[clap(value_enum, default_values = &["app", "postgres", "mongo"])]
+ pub services: Vec,
+}
+
+impl TerminalCommand {
+ pub async fn run(&self, context: &mut CommandContext) -> Result<()> {
+ log::debug!("Run terminal command {:?}", self);
+ if self.need_config_reload() {
+ context.load_config()?;
+ }
+ match self {
+ TerminalCommand::Exit => {}
+ TerminalCommand::Expose(expose) => {
+ lenra::generate_app_env(context, false).await?;
+ lenra::start_env(context).await?;
+
+ context.expose = expose.services.clone();
+ }
+ TerminalCommand::Build(build) => build.run(context).await?,
+ TerminalCommand::Start(start) => start.run(context).await?,
+ TerminalCommand::Logs(logs) => logs.run(context).await?,
+ TerminalCommand::Stop(stop) => stop.run(context).await?,
+ TerminalCommand::Dev(dev) => dev.run(context).await?,
+ TerminalCommand::Upgrade(upgrade) => upgrade.run(context).await?,
+ TerminalCommand::Update(update) => update.run(context).await?,
+ TerminalCommand::Check(check) => check.run(context).await?,
+ TerminalCommand::Reload(reload) => reload.run(context).await?,
+ };
+ Ok(())
+ }
+
+ fn need_config_reload(&self) -> bool {
+ match self {
+ TerminalCommand::Build(_)
+ | TerminalCommand::Start(_)
+ | TerminalCommand::Reload(_)
+ | TerminalCommand::Update(_) => true,
+ _ => false,
+ }
+ }
+}
diff --git a/src/cli/update.rs b/src/cli/update.rs
new file mode 100644
index 0000000..e48750c
--- /dev/null
+++ b/src/cli/update.rs
@@ -0,0 +1,33 @@
+use async_trait::async_trait;
+pub use clap::Args;
+
+use crate::cli::CliCommand;
+use crate::docker_compose::Service;
+use crate::errors::Result;
+use crate::lenra;
+
+use super::{loader, CommandContext};
+
+#[derive(Args, Debug, Clone)]
+pub struct Update {
+ /// The service list to pull
+ #[clap(value_enum, default_values = &["devtool", "postgres", "mongo"])]
+ pub services: Vec,
+}
+
+#[async_trait]
+impl CliCommand for Update {
+ async fn run(&self, context: &mut CommandContext) -> Result<()> {
+ update_loader(context, &self.services).await
+ }
+}
+
+pub async fn update_loader(context: &mut CommandContext, services: &Vec) -> Result<()> {
+ loader(
+ "Update environment images...",
+ "Environment images updated",
+ "Failed updating environment images",
+ || async { lenra::update_env_images(context, services).await },
+ )
+ .await
+}
diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs
new file mode 100644
index 0000000..9715199
--- /dev/null
+++ b/src/cli/upgrade.rs
@@ -0,0 +1,18 @@
+use async_trait::async_trait;
+use clap;
+
+use crate::cli::CliCommand;
+use crate::errors::Result;
+use crate::lenra;
+
+use super::CommandContext;
+
+#[derive(clap::Args, Debug, Clone)]
+pub struct Upgrade;
+
+#[async_trait]
+impl CliCommand for Upgrade {
+ async fn run(&self, _context: &mut CommandContext) -> Result<()> {
+ lenra::upgrade_app().await
+ }
+}
diff --git a/src/command.rs b/src/command.rs
new file mode 100644
index 0000000..e5bf134
--- /dev/null
+++ b/src/command.rs
@@ -0,0 +1,47 @@
+use std::process::{Output, Stdio};
+
+use tokio::process::Command;
+
+use crate::errors::{CommandError, Error, Result};
+
+static mut INHERIT_STDIO: bool = false;
+
+pub fn is_inherit_stdio() -> bool {
+ unsafe { INHERIT_STDIO }
+}
+
+pub fn set_inherit_stdio(val: bool) {
+ unsafe {
+ INHERIT_STDIO = val;
+ }
+}
+
+pub fn create_command(cmd: &str) -> Command {
+ let mut cmd = Command::new(cmd);
+ cmd.kill_on_drop(true);
+ if is_inherit_stdio() {
+ cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
+ } else {
+ cmd.stdout(Stdio::null()).stderr(Stdio::null());
+ }
+ cmd
+}
+
+pub async fn run_command(command: Command) -> Result {
+ let mut command = Command::from(command);
+ let output = command.output().await?;
+
+ if !output.status.success() {
+ return Err(Error::Command(CommandError { command, output }));
+ }
+
+ Ok(output)
+}
+
+pub async fn get_command_output(command: Command) -> Result {
+ let output = run_command(command).await?;
+
+ String::from_utf8(output.stdout)
+ .map(|name| name.trim().to_string())
+ .map_err(Error::from)
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..667f84e
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,421 @@
+use std::{collections::HashMap, fmt::Debug, fs, path::PathBuf};
+
+use dofigen_lib::{
+ self, from_file_path, generate_dockerfile, generate_dockerignore, Artifact, Builder,
+ Healthcheck,
+};
+use serde::{Deserialize, Serialize};
+use serde_yaml::Value;
+
+use crate::{
+ cli::CommandContext,
+ docker_compose::generate_docker_compose,
+ errors::{Error, Result},
+};
+
+pub const DEFAULT_CONFIG_FILE: &str = "lenra.yml";
+pub const LENRA_CACHE_DIRECTORY: &str = ".lenra";
+
+pub const DOCKERFILE_DEFAULT_PATH: [&str; 2] = [LENRA_CACHE_DIRECTORY, "Dockerfile"];
+pub const DOCKERIGNORE_DEFAULT_PATH: [&str; 2] = [LENRA_CACHE_DIRECTORY, "Dockerfile.dockerignore"];
+pub const DOCKERCOMPOSE_DEFAULT_PATH: [&str; 2] = [LENRA_CACHE_DIRECTORY, "compose.yml"];
+
+pub const OF_WATCHDOG_BUILDER: &str = "of-watchdog";
+pub const OF_WATCHDOG_IMAGE: &str = "ghcr.io/openfaas/of-watchdog";
+pub const OF_WATCHDOG_VERSION: &str = "0.9.10";
+
+pub fn load_config_file(path: &std::path::PathBuf) -> Result {
+ let file = fs::File::open(path).map_err(|err| Error::OpenFile(err, path.clone()))?;
+ match path.extension() {
+ Some(os_str) => match os_str.to_str() {
+ Some("yml" | "yaml" | "json") => {
+ Ok(serde_yaml::from_reader(file).map_err(Error::from)?)
+ }
+ Some(ext) => Err(Error::Custom(format!(
+ "Not managed config file extension {}",
+ ext
+ ))),
+ None => Err(Error::Custom(
+ "The config file has no extension".to_string(),
+ )),
+ },
+ None => Err(Error::Custom(
+ "The config file has no extension".to_string(),
+ )),
+ }
+}
+
+/** The main component of the config file */
+#[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone)]
+#[serde(rename_all = "camelCase")]
+pub struct Application {
+ pub path: Option,
+ pub generator: Generator,
+ pub dev: Option,
+}
+
+/** The dev specific configuration */
+#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
+pub struct Dev {
+ pub app: Option,
+ pub devtool: Option,
+ pub postgres: Option,
+ pub mongo: Option,
+ pub dofigen: Option,
+}
+
+/** A Docker image */
+#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
+pub struct Image {
+ pub image: Option,
+ pub tag: Option,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
+pub struct DebugDofigen {
+ pub cmd: Option>,
+ pub ports: Option>,
+}
+
+/** The application generator configuration */
+#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
+#[serde(untagged)]
+pub enum Generator {
+ Dofigen(Dofigen),
+ DofigenFile(DofigenFile),
+ DofigenError { dofigen: Value },
+ Dockerfile(Dockerfile),
+ Docker(Docker),
+ Unknow,
+}
+
+/** The Dofigen configuration */
+#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
+pub struct Dofigen {
+ pub dofigen: dofigen_lib::Image,
+}
+
+/** The Dofigen configuration file */
+#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
+pub struct DofigenFile {
+ pub dofigen: std::path::PathBuf,
+}
+
+/** The Docker configuration */
+#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
+pub struct Docker {
+ pub docker: String,
+ pub ignore: Option,
+}
+
+/** The Docker configuration file */
+#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Default)]
+pub struct Dockerfile {
+ pub docker: std::path::PathBuf,
+}
+
+impl Application {
+ /// Generates all the files needed to build and run the application
+ pub async fn generate_files(&self, context: &mut CommandContext, debug: bool) -> Result<()> {
+ self.generate_docker_files(context, debug)?;
+ self.generate_docker_compose_file(context, debug).await?;
+ Ok(())
+ }
+
+ pub fn generate_docker_files(&self, context: &mut CommandContext, debug: bool) -> Result<()> {
+ log::info!("Docker files generation");
+ // create the `.lenra` cache directory
+ fs::create_dir_all(context.resolve_path(&PathBuf::from(LENRA_CACHE_DIRECTORY))).unwrap();
+
+ match &self.generator {
+ // If args '--prod' is passed then not debug
+ Generator::Dofigen(dofigen) => {
+ self.build_dofigen(context, dofigen.dofigen.clone(), debug)
+ }
+ Generator::DofigenFile(dofigen_file) => self.build_dofigen(
+ context,
+ from_file_path(&context.resolve_path(&dofigen_file.dofigen))
+ .map_err(Error::from)?,
+ debug,
+ ),
+ Generator::DofigenError { dofigen: _ } => Err(Error::Custom(
+ "Your Dofigen configuration is not correct".into(),
+ )),
+ Generator::Dockerfile(_dockerfile) => Ok(()),
+ Generator::Docker(docker) => {
+ self.save_docker_content(context, docker.docker.clone(), docker.ignore.clone())
+ }
+ Generator::Unknow => Err(Error::Custom("Not managed generator".into())),
+ }
+ }
+
+ pub async fn generate_docker_compose_file(
+ &self,
+ context: &mut CommandContext,
+ debug: bool,
+ ) -> Result<()> {
+ log::info!("Docker Compose file generation");
+ // create the `.lenra` cache directory
+ fs::create_dir_all(context.resolve_path(&PathBuf::from(LENRA_CACHE_DIRECTORY)))
+ .map_err(Error::from)?;
+
+ let dockerfile: PathBuf = if let Generator::Dockerfile(file_conf) = &self.generator {
+ context.resolve_path(&file_conf.docker.clone())
+ } else {
+ context.resolve_path(&DOCKERFILE_DEFAULT_PATH.iter().collect())
+ };
+
+ generate_docker_compose(context, dockerfile, &self.dev, debug)
+ .await
+ .map_err(Error::from)?;
+ Ok(())
+ }
+
+ /// Builds a Docker image from a Dofigen structure
+ fn build_dofigen(
+ &self,
+ context: &mut CommandContext,
+ image: dofigen_lib::Image,
+ debug: bool,
+ ) -> Result<()> {
+ // Generate the Dofigen config with OpenFaaS overlay to handle the of-watchdog
+ let overlay = self.dofigen_of_overlay(image)?;
+
+ // when debug add cmd and ports to the Dofigen descriptor
+ let overlay = if debug {
+ self.dofigen_debug_overlay(overlay)?
+ } else {
+ overlay
+ };
+
+ // generate the Dockerfile and .dockerignore files with Dofigen
+ let dockerfile = generate_dockerfile(&overlay);
+ let dockerignore = generate_dockerignore(&overlay);
+ self.save_docker_content(context, dockerfile, Some(dockerignore))
+ }
+
+ fn dofigen_debug_overlay(&self, image: dofigen_lib::Image) -> Result {
+ log::info!("Adding debug overlay to the Dofigen descriptor");
+ let mut debug_overlay = image;
+ if let Some(dev) = &self.dev {
+ if let Some(dofigen) = &dev.dofigen {
+ if let Some(cmd) = &dofigen.cmd {
+ let mut envs = debug_overlay.envs.unwrap();
+ envs.insert("fprocess".to_string(), cmd.join(" "));
+ debug_overlay.envs = Some(envs);
+ }
+ if let Some(ports) = &dofigen.ports {
+ debug_overlay.ports = Some(
+ debug_overlay
+ .ports
+ .unwrap()
+ .into_iter()
+ .chain(ports.into_iter().map(|&value| value))
+ .collect(),
+ )
+ }
+ }
+ }
+ Ok(debug_overlay)
+ }
+
+ /// Add an overlay to the given Dofigen structure to manage OpenFaaS
+ fn dofigen_of_overlay(&self, image: dofigen_lib::Image) -> Result {
+ log::info!("Adding OpenFaaS overlay to the Dofigen descriptor");
+ let mut builders = if let Some(vec) = image.builders {
+ vec
+ } else {
+ Vec::new()
+ };
+ // add of-watchdog builder
+ builders.push(Builder {
+ name: Some(String::from(OF_WATCHDOG_BUILDER)),
+ image: format!("{}:{}", OF_WATCHDOG_IMAGE, OF_WATCHDOG_VERSION),
+ ..Default::default()
+ });
+
+ let mut artifacts = if let Some(arts) = image.artifacts {
+ arts
+ } else {
+ Vec::new()
+ };
+ // get of-watchdog artifact
+ artifacts.push(Artifact {
+ builder: OF_WATCHDOG_BUILDER.to_string(),
+ source: "/fwatchdog".to_string(),
+ destination: "/fwatchdog".to_string(),
+ });
+
+ let mut envs = if let Some(envs) = image.envs {
+ envs
+ } else {
+ HashMap::new()
+ };
+
+ let mut healthcheck = None;
+ // http mode (not if empty)
+ if let Some(ports) = image.ports {
+ if ports.len() > 1 {
+ return Err(Error::Custom(
+ "More than one port has been defined in the Dofigen descriptor".into(),
+ ));
+ }
+ if ports.len() == 1 {
+ envs.insert("mode".to_string(), "http".to_string());
+ envs.insert(
+ "upstream_url".to_string(),
+ format!("http://127.0.0.1:{}", ports[0]),
+ );
+ envs.insert("suppress_lock".to_string(), "true".to_string());
+ if !envs.contains_key("exec_timeout") {
+ envs.insert("exec_timeout".to_string(), "3600".to_string());
+ }
+ if !envs.contains_key("read_timeout") {
+ envs.insert("read_timeout".to_string(), "3600".to_string());
+ }
+ if !envs.contains_key("write_timeout") {
+ envs.insert("write_timeout".to_string(), "3600".to_string());
+ }
+ // handle healthcheck
+ healthcheck = Some(Healthcheck {
+ cmd: "curl --fail http://localhost:8080/_/health".into(),
+ start: Some("3s".into()),
+ interval: Some("3s".into()),
+ timeout: Some("1s".into()),
+ retries: Some(10),
+ });
+ }
+ };
+
+ // prevent custom entrypoint
+ if image.entrypoint.is_some() {
+ return Err(Error::Custom(
+ "The Dofigen descriptor can't have entrypoint defined. Use cmd instead".into(),
+ ));
+ }
+
+ // envs.insert("exec_timeout".to_string(), "0".to_string());
+
+ if let Some(cmd) = image.cmd {
+ envs.insert("fprocess".to_string(), cmd.join(" "));
+ } else {
+ return Err(Error::Custom(
+ "The Dofigen cmd property is not defined".into(),
+ ));
+ }
+
+ Ok(dofigen_lib::Image {
+ image: image.image,
+ builders: Some(builders),
+ artifacts: Some(artifacts),
+ ports: Some(vec![8080]),
+ envs: Some(envs),
+ entrypoint: None,
+ cmd: Some(vec!["/fwatchdog".to_string()]),
+ user: image.user,
+ workdir: image.workdir,
+ adds: image.adds,
+ root: image.root,
+ script: image.script,
+ caches: image.caches,
+ healthcheck: healthcheck,
+ ignores: image.ignores,
+ })
+ }
+
+ /// Saves the Dockerfile and dockerignore (if present) files from their contents
+ fn save_docker_content(
+ &self,
+ context: &mut CommandContext,
+ dockerfile_content: String,
+ dockerignore_content: Option,
+ ) -> Result<()> {
+ let dockerfile_path: PathBuf =
+ context.resolve_path(&DOCKERFILE_DEFAULT_PATH.iter().collect());
+ let dockerignore_path: PathBuf =
+ context.resolve_path(&DOCKERIGNORE_DEFAULT_PATH.iter().collect());
+
+ fs::write(dockerfile_path, dockerfile_content)?;
+ if let Some(content) = dockerignore_content {
+ fs::write(dockerignore_path, content)?;
+ }
+ Ok(())
+ }
+}
+
+impl Default for Generator {
+ fn default() -> Self {
+ Generator::Unknow
+ }
+}
+
+#[cfg(test)]
+mod dofigen_of_overlay_tests {
+ use super::*;
+
+ #[test]
+ fn simple_image() {
+ let image = dofigen_lib::Image {
+ image: "my-dockerimage".into(),
+ cmd: Some(vec!["/app/my-app".into()]),
+ ..Default::default()
+ };
+ let overlayed_image = dofigen_lib::Image {
+ builders: Some(vec![Builder {
+ name: Some("of-watchdog".into()),
+ image: format!("ghcr.io/openfaas/of-watchdog:{}", OF_WATCHDOG_VERSION),
+ ..Default::default()
+ }]),
+ image: String::from("my-dockerimage"),
+ envs: Some(
+ [
+ // ("exec_timeout".to_string(), "0".to_string()),
+ ("fprocess".to_string(), "/app/my-app".to_string()),
+ ]
+ .into(),
+ ),
+ artifacts: Some(vec![Artifact {
+ builder: "of-watchdog".into(),
+ source: "/fwatchdog".into(),
+ destination: "/fwatchdog".into(),
+ }]),
+ ports: Some(vec![8080]),
+ cmd: Some(vec!["/fwatchdog".into()]),
+ ..Default::default()
+ };
+ let config = Application {
+ generator: Generator::Dofigen(Dofigen {
+ dofigen: image.clone(),
+ }),
+ ..Default::default()
+ };
+
+ assert_eq!(config.dofigen_of_overlay(image).unwrap(), overlayed_image);
+ }
+
+ #[test]
+ #[should_panic]
+ fn no_cmd() {
+ let image = dofigen_lib::Image {
+ image: "my-dockerimage".into(),
+ ..Default::default()
+ };
+ let config = Application {
+ generator: Generator::Dofigen(Dofigen {
+ dofigen: image.clone(),
+ }),
+ ..Default::default()
+ };
+ config.dofigen_of_overlay(image).unwrap();
+ }
+}
+
+impl Image {
+ pub fn to_image(&self, default_image: &str, default_tag: &str) -> String {
+ format!(
+ "{}:{}",
+ self.image.clone().unwrap_or(default_image.to_string()),
+ self.tag.clone().unwrap_or(default_tag.to_string())
+ )
+ }
+}
diff --git a/src/devtool.rs b/src/devtool.rs
new file mode 100644
index 0000000..8efb0bc
--- /dev/null
+++ b/src/devtool.rs
@@ -0,0 +1,23 @@
+use log::debug;
+
+use crate::{
+ cli::CommandContext,
+ docker_compose::{execute_compose_service_command, Service},
+ errors::Result,
+};
+
+pub async fn stop_app_env(context: &mut CommandContext) -> Result<()> {
+ debug!("Stop app environment");
+ execute_compose_service_command(
+ context,
+ Service::Devtool,
+ &[
+ "bin/dev_tools",
+ "rpc",
+ "ApplicationRunner.Environment.DynamicSupervisor.stop_env(1)",
+ ],
+ )
+ .await?;
+ debug!("App environment stopped");
+ Ok(())
+}
diff --git a/src/docker.rs b/src/docker.rs
new file mode 100644
index 0000000..28db242
--- /dev/null
+++ b/src/docker.rs
@@ -0,0 +1,58 @@
+use std::{
+ collections::hash_map::DefaultHasher,
+ hash::{Hash, Hasher},
+};
+
+use regex::Regex;
+
+pub fn normalize_tag(tag: String) -> String {
+ let re = Regex::new(r"[^A-Za-z0-9._-]").unwrap();
+ let tag = re.replace_all(tag.as_str(), "-").to_string();
+ if tag.len() > 63 {
+ let mut hacher = DefaultHasher::new();
+ tag.hash(&mut hacher);
+ let hash = format!("{:X}", hacher.finish());
+ format!(
+ "{}{}",
+ tag.chars().take(63 - hash.len()).collect::(),
+ hash
+ )
+ } else {
+ tag
+ }
+}
+
+#[cfg(test)]
+mod test_normalize_tag {
+ use std::{
+ collections::hash_map::DefaultHasher,
+ hash::{Hash, Hasher},
+ };
+
+ use regex::Regex;
+
+ use super::normalize_tag;
+
+ #[test]
+ fn prefixed_tag_name() {
+ let tag_name = "prefixed/branch-name".to_string();
+ assert_eq!(normalize_tag(tag_name), "prefixed-branch-name".to_string());
+ }
+
+ #[test]
+ fn long_tag_name() {
+ let tag_name =
+ "prefixed/branch-name/with_many-many.many_many&many-many/underscore".to_string();
+ let re = Regex::new(r"[^A-Za-z0-9._-]").unwrap();
+ let tag = re.replace_all(tag_name.as_str(), "-").to_string();
+ let mut hacher = DefaultHasher::new();
+ tag.hash(&mut hacher);
+ let hash = format!("{:X}", hacher.finish());
+ let tag = format!(
+ "{}{}",
+ tag.chars().take(63 - hash.len()).collect::(),
+ hash
+ );
+ assert_eq!(normalize_tag(tag_name), tag.to_string());
+ }
+}
diff --git a/src/docker_compose.rs b/src/docker_compose.rs
new file mode 100644
index 0000000..3911bdb
--- /dev/null
+++ b/src/docker_compose.rs
@@ -0,0 +1,669 @@
+use docker_compose_types::{
+ AdvancedBuildStep, BuildStep, Command, Compose, DependsCondition, DependsOnOptions, Deploy,
+ EnvTypes, Environment, Healthcheck, HealthcheckTest, Limits, Resources, Services,
+};
+use itertools::Itertools;
+use lazy_static::lazy_static;
+use log::{debug, warn};
+use serde::{Deserialize, Serialize};
+use std::process::Stdio;
+use std::{convert::TryInto, env, fs, path::PathBuf};
+use strum::Display;
+use tokio::process;
+
+use crate::cli::CommandContext;
+use crate::command::{get_command_output, is_inherit_stdio};
+use crate::config::Image;
+use crate::docker::normalize_tag;
+use crate::errors::Error;
+use crate::{
+ config::{Dev, DOCKERCOMPOSE_DEFAULT_PATH},
+ errors::{CommandError, Result},
+ git::get_current_branch,
+};
+
+pub const APP_SERVICE_NAME: &str = "app";
+pub const DEVTOOL_SERVICE_NAME: &str = "devtool";
+pub const POSTGRES_SERVICE_NAME: &str = "postgres";
+pub const MONGO_SERVICE_NAME: &str = "mongo";
+const APP_BASE_IMAGE: &str = "lenra/app/";
+const APP_DEFAULT_IMAGE: &str = "my";
+const APP_DEFAULT_IMAGE_TAG: &str = "latest";
+const DEVTOOL_IMAGE: &str = "lenra/devtools";
+const DEVTOOL_DEFAULT_TAG: &str = "beta";
+const POSTGRES_IMAGE: &str = "postgres";
+const POSTGRES_IMAGE_TAG: &str = "13";
+const MONGO_IMAGE: &str = "mongo";
+const MONGO_IMAGE_TAG: &str = "5";
+pub const OF_WATCHDOG_PORT: u16 = 8080;
+pub const DEVTOOL_WEB_PORT: u16 = 4000;
+pub const DEVTOOL_API_PORT: u16 = 4001;
+pub const DEVTOOL_OAUTH_PORT: u16 = 4444;
+pub const MONGO_PORT: u16 = 27017;
+pub const POSTGRES_PORT: u16 = 5432;
+pub const NON_ROOT_USER: &str = "12000";
+const MEMORY_RESERVATION: &str = "128M";
+const MEMORY_LIMIT: &str = "256M";
+
+lazy_static! {
+ static ref COMPOSE_COMMAND: std::process::Command = get_compose_command();
+}
+
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
+#[serde(rename_all = "PascalCase")]
+pub struct ServiceInformations {
+ service: Service,
+ #[serde(rename = "ID")]
+ id: String,
+ name: String,
+ image: String,
+ command: String,
+ project: String,
+ state: ServiceState,
+ status: String,
+ created: String,
+ health: String,
+ exit_code: String,
+ publishers: Vec,
+}
+
+#[derive(Serialize, Deserialize, Debug, PartialEq)]
+#[serde(rename_all = "PascalCase")]
+pub struct Publisher {
+ #[serde(rename = "URL")]
+ url: String,
+ target_port: u16,
+ published_port: u16,
+ protocol: String,
+}
+
+#[derive(clap::ValueEnum, Serialize, Deserialize, Clone, Debug, PartialEq, Display)]
+pub enum Service {
+ App,
+ Devtool,
+ Postgres,
+ Mongo,
+}
+
+impl Service {
+ pub fn to_str(&self) -> &str {
+ match self {
+ Service::App => APP_SERVICE_NAME,
+ Service::Devtool => DEVTOOL_SERVICE_NAME,
+ Service::Postgres => POSTGRES_SERVICE_NAME,
+ Service::Mongo => MONGO_SERVICE_NAME,
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct ServiceImages {
+ pub app: String,
+ pub devtool: String,
+ pub postgres: String,
+ pub mongo: String,
+}
+
+#[derive(clap::ValueEnum, Serialize, Deserialize, Clone, Debug, PartialEq)]
+pub enum ServiceState {
+ Running,
+ Paused,
+ Restarting,
+ Removing,
+ Dead,
+ Created,
+ Exited,
+}
+
+/// Generates the docker-compose.yml file
+pub async fn generate_docker_compose(
+ context: &mut CommandContext,
+ dockerfile: PathBuf,
+ dev_conf: &Option,
+ debug: bool,
+) -> Result<()> {
+ let compose_content =
+ generate_docker_compose_content(dockerfile, dev_conf, &context.expose, debug).await?;
+ let compose_path: PathBuf = context.resolve_path(&DOCKERCOMPOSE_DEFAULT_PATH.iter().collect());
+ fs::write(compose_path, compose_content).map_err(Error::from)?;
+ Ok(())
+}
+
+async fn generate_docker_compose_content(
+ dockerfile: PathBuf,
+ dev_conf: &Option,
+ exposed_services: &Vec,
+ debug: bool,
+) -> Result {
+ let mut devtool_env_vec: Vec<(String, Option)> = vec![
+ (
+ "POSTGRES_USER".into(),
+ Some(EnvTypes::String("postgres".into())),
+ ),
+ (
+ "POSTGRES_PASSWORD".into(),
+ Some(EnvTypes::String("postgres".into())),
+ ),
+ (
+ "POSTGRES_DB".into(),
+ Some(EnvTypes::String("lenra_devtool".into())),
+ ),
+ ];
+ let postgres_envs: [(String, Option); 3] =
+ devtool_env_vec.clone().try_into().unwrap();
+
+ devtool_env_vec.push((
+ "POSTGRES_HOST".into(),
+ Some(EnvTypes::String(POSTGRES_SERVICE_NAME.into())),
+ ));
+ devtool_env_vec.push((
+ "OF_WATCHDOG_URL".into(),
+ Some(EnvTypes::String(format!(
+ "http://{}:{}",
+ APP_SERVICE_NAME, OF_WATCHDOG_PORT
+ ))),
+ ));
+ devtool_env_vec.push((
+ "LENRA_API_URL".into(),
+ Some(EnvTypes::String(format!(
+ "http://{}:{}",
+ DEVTOOL_SERVICE_NAME, DEVTOOL_API_PORT
+ ))),
+ ));
+ devtool_env_vec.push((
+ "MONGO_HOSTNAME".into(),
+ Some(EnvTypes::String(MONGO_SERVICE_NAME.into())),
+ ));
+ let devtool_envs: [(String, Option); 7] = devtool_env_vec.try_into().unwrap();
+
+ let mongo_envs: [(String, Option); 2] = [
+ (
+ "MONGO_INITDB_DATABASE".to_string(),
+ Some(EnvTypes::String("test".into())),
+ ),
+ (
+ "CONFIG".to_string(),
+ Some(EnvTypes::String(format!(
+ r#"{{"_id" : "rs0", "members" : [{{"_id" : 0,"host" : "{}:{}"}}]}}"#,
+ MONGO_SERVICE_NAME, MONGO_PORT
+ ))),
+ ),
+ ];
+
+ let service_images = get_services_images(dev_conf).await;
+ let mut app_ports = vec![];
+ if exposed_services.contains(&Service::App) {
+ app_ports.push(port_to_port_binding(OF_WATCHDOG_PORT));
+ }
+ if debug {
+ if let Some(conf) = dev_conf {
+ if let Some(dofigen) = &conf.dofigen {
+ if let Some(ports) = &dofigen.ports {
+ for port in ports {
+ app_ports.push(port_to_port_binding(*port));
+ }
+ }
+ }
+ }
+ }
+
+ let compose = Compose {
+ services: Some(Services(
+ [
+ (
+ APP_SERVICE_NAME.into(),
+ Some(docker_compose_types::Service {
+ image: Some(service_images.app),
+ ports: if !app_ports.is_empty() { Some(app_ports)} else {None},
+ build_: Some(BuildStep::Advanced(AdvancedBuildStep {
+ context: "..".into(),
+ dockerfile: Some(dockerfile.to_str().unwrap().into()),
+ ..Default::default()
+ })),
+ user: Some(NON_ROOT_USER.into()),
+ deploy: Some(Deploy {
+ resources: Some(Resources {
+ limits: Some(Limits {
+ memory: Some(MEMORY_LIMIT.into()),
+ ..Default::default()
+ }),
+ reservations: Some(Limits {
+ memory: Some(MEMORY_RESERVATION.into()),
+ ..Default::default()
+ })
+ }),
+ ..Default::default()
+ }),
+ // TODO: Add resources management when managed by the docker-compose-types lib
+ ..Default::default()
+ }),
+ ),
+ (
+ DEVTOOL_SERVICE_NAME.into(),
+ Some(docker_compose_types::Service {
+ image: Some(service_images.devtool),
+ ports: Some(vec![DEVTOOL_WEB_PORT, DEVTOOL_API_PORT, DEVTOOL_OAUTH_PORT].into_iter().map(port_to_port_binding).collect()),
+ environment: Some(Environment::KvPair(devtool_envs.into())),
+ healthcheck: Some(Healthcheck {
+ test: Some(HealthcheckTest::Multiple(vec![
+ "CMD".into(),
+ "wget".into(),
+ "--spider".into(),
+ "-q".into(),
+ "http://localhost:4000/health".into(),
+ ])),
+ start_period: Some("10s".into()),
+ interval: Some("1s".into()),
+ timeout: None,
+ retries: 5,
+ disable: false,
+ }),
+ depends_on: Some(DependsOnOptions::Conditional(
+ [(
+ POSTGRES_SERVICE_NAME.into(),
+ DependsCondition {
+ condition: "service_healthy".into(),
+ },
+ ),(
+ MONGO_SERVICE_NAME.into(),
+ DependsCondition {
+ condition: "service_healthy".into(),
+ },
+ )]
+ .into(),
+ )),
+ ..Default::default()
+ }),
+ ),
+ (
+ POSTGRES_SERVICE_NAME.into(),
+ Some(
+ docker_compose_types::Service {
+ image: Some(service_images.postgres),
+ ports: if exposed_services.contains(&Service::Postgres) {Some(vec![port_to_port_binding(POSTGRES_PORT)])} else {None},
+ environment: Some(Environment::KvPair(postgres_envs.into())),
+ healthcheck: Some(Healthcheck {
+ test: Some(HealthcheckTest::Multiple(vec![
+ "CMD".into(),
+ "pg_isready".into(),
+ "-U".into(),
+ "postgres".into(),
+ ])),
+ start_period: Some("5s".into()),
+ interval: Some("1s".into()),
+ timeout: None,
+ retries: 5,
+ disable: false,
+ }),
+ ..Default::default()
+ }
+ ),
+ ),
+ (
+ MONGO_SERVICE_NAME.into(),
+ Some( docker_compose_types::Service {
+ image: Some(service_images.mongo),
+ ports: if exposed_services.contains(&Service::Mongo) {Some(vec![port_to_port_binding(MONGO_PORT)])} else {None},
+ environment: Some(Environment::KvPair(mongo_envs.into())),
+ healthcheck: Some(Healthcheck {
+ test: Some(HealthcheckTest::Single(r#"test $$(echo "rs.initiate($$CONFIG).ok || rs.status().ok" | mongo --quiet) -eq 1"#.to_string())),
+ start_period: Some("5s".into()),
+ interval: Some("1s".into()),
+ timeout: None,
+ retries: 5,
+ disable: false,
+ }),
+ command: Some(Command::Simple("mongod --replSet rs0".into())),
+ ..Default::default()
+ }
+ ),
+ ),
+ ]
+ .into(),
+ )),
+ ..Default::default()
+ };
+ serde_yaml::to_string(&compose).map_err(Error::from)
+}
+
+fn port_to_port_binding(port: u16) -> String {
+ format!("{}:{}", port, port)
+}
+
+pub fn create_compose_command(context: &mut CommandContext) -> process::Command {
+ let dockercompose_path: PathBuf =
+ context.resolve_path(&DOCKERCOMPOSE_DEFAULT_PATH.iter().collect());
+ let mut cmd = process::Command::from(COMPOSE_COMMAND.clone());
+ cmd.arg("-f").arg(dockercompose_path).kill_on_drop(true);
+ if is_inherit_stdio() {
+ cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
+ } else {
+ cmd.stdout(Stdio::null()).stderr(Stdio::null());
+ }
+ cmd
+}
+
+pub async fn compose_up(context: &mut CommandContext) -> Result<()> {
+ let mut command = create_compose_command(context);
+
+ command.arg("up").arg("-d").arg("--wait");
+
+ log::debug!("cmd: {:?}", command);
+ let output = command.spawn()?.wait_with_output().await?;
+
+ if !output.status.success() {
+ warn!(
+ "An error occured while running the docker-compose app:\n{}",
+ CommandError { command, output }
+ )
+ }
+ Ok(())
+}
+
+pub async fn compose_down(context: &mut CommandContext) -> Result<()> {
+ let mut command = create_compose_command(context);
+
+ command.arg("down").arg("--volumes");
+
+ log::debug!("cmd: {:?}", command);
+ let output = command.spawn()?.wait_with_output().await?;
+ if !output.status.success() {
+ warn!("An error occured while stoping the docker-compose app");
+ return Err(Error::Command(CommandError { command, output }));
+ }
+ Ok(())
+}
+
+pub async fn compose_build(context: &mut CommandContext) -> Result<()> {
+ let mut command = create_compose_command(context);
+ command.arg("build");
+
+ // Use Buildkit to improve performance
+ command.env("DOCKER_BUILDKIT", "1");
+
+ log::debug!("cmd: {:?}", command);
+ let output = command.spawn()?.wait_with_output().await?;
+
+ if !output.status.success() {
+ warn!(
+ "An error occured while building the Docker image:\n{}",
+ CommandError { command, output }
+ )
+ }
+ Ok(())
+}
+
+pub async fn compose_pull(context: &mut CommandContext, services: &Vec) -> Result<()> {
+ log::debug!("Pulling services: {:?}", services);
+ let mut command = create_compose_command(context);
+ command.arg("pull");
+ services.iter().for_each(|service| {
+ command.arg(service.to_str());
+ });
+
+ log::debug!("cmd: {:?}", command);
+ let output = command.spawn()?.wait_with_output().await?;
+
+ if !output.status.success() {
+ warn!(
+ "An error occured while building the Docker image:\n{}",
+ CommandError { command, output }
+ )
+ }
+ Ok(())
+}
+
+/// List all the current Docker Compose running services
+pub async fn list_running_services(context: &mut CommandContext) -> Result> {
+ let mut command = create_compose_command(context);
+ command
+ .arg("ps")
+ .arg("--services")
+ .arg("--filter")
+ .arg("status=running");
+
+ let services: Vec = get_command_output(command).await.map(|output| {
+ output
+ .lines()
+ .map(|service| match service.trim() {
+ APP_SERVICE_NAME => Some(Service::App),
+ DEVTOOL_SERVICE_NAME => Some(Service::Devtool),
+ POSTGRES_SERVICE_NAME => Some(Service::Postgres),
+ MONGO_SERVICE_NAME => Some(Service::Mongo),
+ _ => None,
+ })
+ .filter_map(|service| service)
+ .collect()
+ })?;
+ Ok(services)
+}
+
+/// Get the given Docker Compose service information
+pub async fn get_service_informations(
+ context: &mut CommandContext,
+ service: Service,
+) -> Result {
+ let mut command = create_compose_command(context);
+ let service_name = service.to_str();
+ command
+ .arg("ps")
+ .arg(service_name)
+ .arg("--format")
+ .arg("json");
+
+ let output = get_command_output(command).await?;
+ let infos: ServiceInformations = serde_yaml::from_str(output.as_str())?;
+ Ok(infos)
+}
+
+/// Get the given Docker Compose service published port
+pub async fn get_service_published_ports(
+ context: &mut CommandContext,
+ service: Service,
+) -> Result> {
+ let infos = get_service_informations(context, service).await?;
+ let ports = infos
+ .publishers
+ .iter()
+ .map(|publisher| publisher.published_port)
+ .unique()
+ .collect();
+ Ok(ports)
+}
+
+pub async fn execute_compose_service_command(
+ context: &mut CommandContext,
+ service: Service,
+ cmd: &[&str],
+) -> Result {
+ let mut command = create_compose_command(context);
+
+ command.arg("exec").arg(service.to_str());
+
+ cmd.iter().for_each(|&part| {
+ command.arg(part);
+ ()
+ });
+
+ let output = command.output().await.map_err(Error::from)?;
+
+ if !output.status.success() {
+ return Err(Error::from(CommandError { command, output }));
+ }
+
+ String::from_utf8(output.stdout)
+ .map(|name| name.trim().to_string())
+ .map_err(Error::from)
+}
+
+fn current_dir_name() -> Option {
+ if let Ok(path) = env::current_dir() {
+ path.file_name()
+ .map(|name| String::from(name.to_str().unwrap()))
+ } else {
+ None
+ }
+}
+
+pub async fn get_services_images(dev_conf: &Option) -> ServiceImages {
+ let default_app_image = format!(
+ "{}{}",
+ APP_BASE_IMAGE,
+ current_dir_name().unwrap_or(APP_DEFAULT_IMAGE.to_string())
+ );
+ let default_app_tag = match get_current_branch(None).await {
+ Ok(branch_name) => normalize_tag(branch_name),
+ _ => APP_DEFAULT_IMAGE_TAG.to_string(),
+ };
+
+ let dev = dev_conf.clone().unwrap_or(Dev {
+ ..Default::default()
+ });
+ ServiceImages {
+ app: dev
+ .app
+ .unwrap_or(Image {
+ ..Default::default()
+ })
+ .to_image(&default_app_image, &default_app_tag),
+ devtool: dev
+ .devtool
+ .unwrap_or(Image {
+ ..Default::default()
+ })
+ .to_image(DEVTOOL_IMAGE, DEVTOOL_DEFAULT_TAG),
+ postgres: dev
+ .postgres
+ .unwrap_or(Image {
+ ..Default::default()
+ })
+ .to_image(POSTGRES_IMAGE, POSTGRES_IMAGE_TAG),
+ mongo: dev
+ .mongo
+ .unwrap_or(Image {
+ ..Default::default()
+ })
+ .to_image(MONGO_IMAGE, MONGO_IMAGE_TAG),
+ }
+}
+
+fn get_compose_command() -> std::process::Command {
+ match std::process::Command::new("docker-compose")
+ .arg("version")
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ {
+ Ok(_) => {
+ debug!("Using 'docker-compose'");
+ std::process::Command::new("docker-compose")
+ }
+ Err(e) => {
+ if std::io::ErrorKind::NotFound != e.kind() {
+ warn!(
+ "An unexpected error occured while runing 'docker-compose version' {}",
+ e
+ );
+ }
+ debug!("Using 'docker compose'");
+ let mut cmd = std::process::Command::new("docker");
+ cmd.arg("compose");
+ cmd
+ }
+ }
+ .into()
+}
+
+trait CloneCommand {
+ fn clone(&self) -> Self;
+}
+
+impl CloneCommand for std::process::Command {
+ fn clone(&self) -> Self {
+ let mut new = Self::new(self.get_program());
+ new.args(self.get_args());
+ new
+ }
+}
+
+#[cfg(test)]
+mod test_get_services_images {
+ use std::{
+ collections::hash_map::DefaultHasher,
+ hash::{Hash, Hasher},
+ };
+
+ use crate::git;
+
+ use super::*;
+ use mocktopus::mocking::*;
+ use regex::Regex;
+
+ #[tokio::test]
+ async fn basic() {
+ let app_tag = "test";
+ let images: ServiceImages = get_services_images(&Some(Dev {
+ app: Some(Image {
+ image: None,
+ tag: Some(app_tag.into()),
+ }),
+ ..Default::default()
+ }))
+ .await;
+ assert_eq!(images.app, format!("lenra/app/lenra_cli:{}", app_tag));
+ assert_eq!(
+ images.devtool,
+ format!("{}:{}", DEVTOOL_IMAGE, DEVTOOL_DEFAULT_TAG)
+ );
+ assert_eq!(
+ images.postgres,
+ format!("{}:{}", POSTGRES_IMAGE, POSTGRES_IMAGE_TAG)
+ );
+ assert_eq!(images.mongo, format!("{}:{}", MONGO_IMAGE, MONGO_IMAGE_TAG));
+ }
+
+ #[tokio::test]
+ async fn branch_name() {
+ git::get_current_branch
+ .mock_safe(|_| MockResult::Return(Box::pin(async move { Ok("test".to_string()) })));
+ let images: ServiceImages = get_services_images(&None).await;
+ assert_eq!(images.app, "lenra/app/lenra_cli:test".to_string());
+ }
+
+ #[tokio::test]
+ async fn path_branch_name() {
+ git::get_current_branch.mock_safe(|_| {
+ MockResult::Return(Box::pin(async move {
+ Ok("prefixed/branch-name_withUnderscore".to_string())
+ }))
+ });
+ let images: ServiceImages = get_services_images(&None).await;
+ assert_eq!(
+ images.app,
+ "lenra/app/lenra_cli:prefixed-branch-name_withUnderscore".to_string()
+ );
+ }
+
+ #[tokio::test]
+ async fn long_branch_name() {
+ let branch_name =
+ "prefixed/branch-name/with_many-many.many_many-many-many/underscore".to_string();
+ let re = Regex::new(r"[^A-Za-z0-9._-]").unwrap();
+ let tag = re.replace_all(branch_name.as_str(), "-").to_string();
+ let mut hacher = DefaultHasher::new();
+ tag.hash(&mut hacher);
+ let hash = format!("{:X}", hacher.finish());
+ let tag = format!(
+ "{}{}",
+ tag.chars().take(63 - hash.len()).collect::(),
+ hash
+ );
+
+ git::get_current_branch.mock_safe(move |_| {
+ let branch = branch_name.clone();
+ MockResult::Return(Box::pin(async move { Ok(branch) }))
+ });
+ let images: ServiceImages = get_services_images(&None).await;
+ assert_eq!(images.app, format!("lenra/app/lenra_cli:{}", tag));
+ }
+}
diff --git a/src/errors.rs b/src/errors.rs
new file mode 100644
index 0000000..ade1445
--- /dev/null
+++ b/src/errors.rs
@@ -0,0 +1,71 @@
+use std::{process::Output, string::FromUtf8Error};
+
+use rustyline::error::ReadlineError;
+use thiserror::Error;
+use tokio::{process::Command, task::JoinError};
+
+use crate::docker_compose::Service;
+
+pub type Result = std::result::Result;
+
+#[derive(Error, Debug)]
+pub enum Error {
+ #[error("Could not open file {1}: {0}")]
+ OpenFile(std::io::Error, std::path::PathBuf),
+ #[error("StdIO error {0}")]
+ Stdio(#[from] std::io::Error),
+ #[error("Error while deserializing the document: {0}")]
+ Deserialize(#[from] serde_yaml::Error),
+ #[error("{0}")]
+ Dofigen(#[from] dofigen_lib::Error),
+ #[error("Could not read command: {0}")]
+ ReadLine(#[from] ReadlineError),
+ #[error("Could not parse command: {0}")]
+ ParseCommand(#[from] clap::Error),
+ #[error("Error while requesting: {0}")]
+ Request(#[from] ureq::Error),
+ #[error("Error while joining an async task: {0}")]
+ Join(#[from] JoinError),
+ #[error("The command execution failed: {0}")]
+ Command(#[from] CommandError),
+ #[error("{0}")]
+ FromUtf8(#[from] FromUtf8Error),
+ #[error("The {0} service is not exposed")]
+ ServiceNotExposed(Service),
+ #[error("Some services are not started")]
+ NotStartedServices,
+ #[error("The app must be built before running it")]
+ NeverBuiltApp,
+ #[error("The new project directory is not empty")]
+ ProjectPathNotEmpty,
+ #[error("Check error")]
+ Check,
+ #[error("The next GitHub topic is not correct: {0}")]
+ InvalidGitHubTopic(String),
+ #[error("No template found")]
+ NoTemplateFound,
+ #[error("{0}")]
+ Custom(String),
+}
+
+#[derive(Debug)]
+pub struct CommandError {
+ pub command: Command,
+ pub output: Output,
+}
+
+impl std::error::Error for CommandError {}
+
+impl std::fmt::Display for CommandError {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ let output = self.output.clone();
+ write!(
+ f,
+ "Command exec exited with code {}:\n\tcmd: {:?}\n\tstdout: {}\n\tstderr: {}",
+ output.status.code().unwrap(),
+ self.command,
+ String::from_utf8(output.stdout).unwrap(),
+ String::from_utf8(output.stderr).unwrap()
+ )
+ }
+}
diff --git a/src/git.rs b/src/git.rs
new file mode 100644
index 0000000..5453a5b
--- /dev/null
+++ b/src/git.rs
@@ -0,0 +1,69 @@
+use std::path::PathBuf;
+
+use crate::{
+ command::{create_command, get_command_output},
+ errors::{Error, Result},
+};
+use lazy_static::lazy_static;
+use regex::Regex;
+use tokio::process;
+
+#[cfg(test)]
+use mocktopus::macros::mockable;
+
+lazy_static! {
+ pub static ref GIT_REPO_REGEX: Regex =
+ Regex::new(r"^((git|ssh|http(s)?)|(git@[\w\.]+))(:(//)?)([\w\.@\:/\-~]+)(\.git)(/)?$")
+ .unwrap();
+}
+
+pub fn create_git_command() -> process::Command {
+ create_command("git")
+}
+
+#[cfg_attr(test, mockable)]
+pub async fn get_current_branch(git_dir: Option) -> Result {
+ let mut cmd = create_git_command();
+ if let Some(dir) = git_dir {
+ cmd.arg("--git-dir").arg(dir.as_os_str());
+ }
+ cmd.arg("rev-parse").arg("--abbrev-ref").arg("HEAD");
+ get_command_output(cmd).await
+}
+
+#[cfg_attr(test, mockable)]
+pub async fn get_current_commit(git_dir: Option) -> Result {
+ let mut cmd = create_git_command();
+ if let Some(dir) = git_dir {
+ cmd.arg("--git-dir").arg(dir.as_os_str());
+ }
+ cmd.arg("rev-parse").arg("HEAD");
+ get_command_output(cmd).await
+}
+
+pub async fn pull(git_dir: Option) -> Result<()> {
+ log::debug!("git pull {:?}", git_dir);
+ let mut cmd = create_git_command();
+
+ if let Some(dir) = git_dir {
+ cmd.arg("--git-dir").arg(dir.as_os_str());
+ }
+
+ cmd.arg("pull");
+
+ cmd.spawn()?.wait_with_output().await.map_err(Error::from)?;
+
+ Ok(())
+}
+
+#[derive(Clone, Debug)]
+pub struct Repository {
+ pub name: String,
+ pub description: String,
+ pub url: String,
+ pub stars: u32,
+}
+
+pub trait PlatformRepository {
+ fn to_repository(&self) -> Repository;
+}
diff --git a/src/github.rs b/src/github.rs
new file mode 100644
index 0000000..ad3c89a
--- /dev/null
+++ b/src/github.rs
@@ -0,0 +1,56 @@
+use lazy_static::lazy_static;
+use regex::Regex;
+use serde::{Deserialize, Serialize};
+
+use crate::{
+ errors::{Error, Result},
+ git::{PlatformRepository, Repository},
+};
+
+lazy_static! {
+ pub static ref GITHUB_TOPIC_REGEX: Regex = Regex::new(r"^[a-z0-9]+(-[a-z0-9]+)*$").unwrap();
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct GitHubSearchRepoResponse {
+ pub total_count: u32,
+ pub items: Vec,
+}
+
+#[derive(Serialize, Deserialize, Debug)]
+struct GitHubRepository {
+ pub full_name: String,
+ pub description: String,
+ pub clone_url: String,
+ pub stargazers_count: u32,
+}
+
+impl PlatformRepository for GitHubRepository {
+ fn to_repository(&self) -> Repository {
+ Repository {
+ name: self.full_name.clone(),
+ description: self.description.clone(),
+ url: self.clone_url.clone(),
+ stars: self.stargazers_count,
+ }
+ }
+}
+
+pub async fn search_repositories(query: &str) -> Result> {
+ let request = format!(
+ "https://api.github.com/search/repositories?q={}&sort=stars&order=desc",
+ query
+ );
+ log::debug!("search repositories on GitHub: {}", request);
+ let reponse: GitHubSearchRepoResponse = ureq::get(request.as_str())
+ .call()
+ .map_err(Error::from)?
+ .into_json()
+ .map_err(Error::from)?;
+
+ reponse
+ .items
+ .into_iter()
+ .map(|repo| Ok(repo.to_repository()))
+ .collect()
+}
diff --git a/src/keyboard_event.rs b/src/keyboard_event.rs
new file mode 100644
index 0000000..70000ea
--- /dev/null
+++ b/src/keyboard_event.rs
@@ -0,0 +1,127 @@
+use log::{debug, warn};
+use rustyline::{
+ Cmd, ConditionalEventHandler, Editor, Event, EventContext, EventHandler, Helper, KeyCode,
+ KeyEvent, Modifiers, RepeatCount,
+};
+
+use crate::errors::{Error, Result};
+
+#[derive(Debug)]
+struct KeyEventHandler
+where
+ F: Fn() -> Option + Send + Sync + 'static,
+{
+ event: KeyEvent,
+ listener: F,
+}
+
+pub trait KeyEventListener
+where
+ F: Fn() -> Option + Send + Sync + 'static,
+{
+ fn add_listener(&mut self, event: KeyEvent, listener: F) -> &mut Self;
+}
+
+impl KeyEventListener for Editor
+where
+ F: Fn() -> Option + Send + Sync + 'static,
+ H: Helper,
+{
+ fn add_listener(&mut self, event: KeyEvent, listener: F) -> &mut Self {
+ let normalized_event = KeyEvent::normalize(event);
+ self.bind_sequence(
+ normalized_event.clone(),
+ EventHandler::Conditional(Box::new(KeyEventHandler {
+ event: normalized_event,
+ listener: listener,
+ })),
+ );
+ self
+ }
+}
+
+impl ConditionalEventHandler for KeyEventHandler
+where
+ F: Fn() -> Option + Send + Sync,
+{
+ fn handle(&self, evt: &Event, _: RepeatCount, _: bool, _: &EventContext) -> Option {
+ debug!("KeyEventHandler: {:?}", evt);
+ if let Some(k) = evt.get(0) {
+ let key = KeyEvent::normalize(*k);
+ debug!("KeyEventHandler: {:?}", key);
+ if key == self.event {
+ return (self.listener)();
+ }
+ } else {
+ warn!("KeyEventHandler without key");
+ }
+ Some(Cmd::Insert(0, "".into()))
+ }
+}
+
+pub struct KeyboardListener {
+ editor: Editor<()>,
+}
+
+impl KeyEventListener for KeyboardListener
+where
+ F: Fn() -> Option + Send + Sync + 'static,
+{
+ fn add_listener(&mut self, event: KeyEvent, listener: F) -> &mut Self {
+ self.editor.add_listener(event, listener);
+ self
+ }
+}
+
+impl KeyboardListener {
+ pub async fn listen(mut self) -> Result<()> {
+ tokio::task::spawn_blocking(move || self.editor.readline("").map_err(Error::from))
+ .await
+ .map_err(Error::from)?
+ .ok();
+ Ok(())
+ }
+
+ pub fn new() -> Result {
+ Ok(KeyboardListener {
+ editor: Editor::new()?,
+ })
+ }
+}
+
+pub fn keyevent_to_string(event: KeyEvent) -> String {
+ let mut parts = Vec::new();
+ if event.1 & Modifiers::CTRL == Modifiers::CTRL {
+ parts.push("Ctrl".to_string());
+ }
+ if event.1 & Modifiers::ALT == Modifiers::ALT {
+ parts.push("Alt".to_string());
+ }
+ if event.1 & Modifiers::SHIFT == Modifiers::SHIFT {
+ parts.push("Shift".to_string());
+ }
+ parts.push(keycode_to_string(event.0));
+ parts.join("+")
+}
+
+fn keycode_to_string(keycode: KeyCode) -> String {
+ match keycode {
+ KeyCode::Backspace => "⌫".to_string(),
+ KeyCode::Char(c) => c.to_string().to_uppercase(),
+ KeyCode::Delete => "Del".to_string(),
+ KeyCode::Down => "Down".to_string(),
+ KeyCode::End => "End".to_string(),
+ KeyCode::Enter => "Enter".to_string(),
+ KeyCode::Esc => "Esc".to_string(),
+ KeyCode::F(num) => format!("F{}", num),
+ KeyCode::Home => "Home".to_string(),
+ KeyCode::Insert => "Insert".to_string(),
+ KeyCode::Left => "Left".to_string(),
+ KeyCode::PageDown => "Page Down".to_string(),
+ KeyCode::PageUp => "Page Up".to_string(),
+ KeyCode::Right => "Right".to_string(),
+ KeyCode::Tab => "Tab".to_string(),
+ KeyCode::Up => "Up".to_string(),
+ _ => todo!(),
+ }
+}
diff --git a/src/lenra.rs b/src/lenra.rs
new file mode 100644
index 0000000..fb7ee88
--- /dev/null
+++ b/src/lenra.rs
@@ -0,0 +1,226 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+ process::Stdio,
+};
+
+use rustyline::Editor;
+
+use crate::{
+ cli::CommandContext,
+ command::get_command_output,
+ config::{DOCKERCOMPOSE_DEFAULT_PATH, LENRA_CACHE_DIRECTORY},
+ devtool::stop_app_env,
+ docker_compose::{
+ self, compose_build, compose_down, compose_up, list_running_services, Service,
+ },
+ errors::{Error, Result},
+ git,
+ template::{self, TemplateData},
+};
+
+#[cfg(test)]
+use mocktopus::macros::mockable;
+
+#[cfg_attr(test, mockable)]
+pub async fn create_new_project(template: &str, path: &PathBuf) -> Result<()> {
+ log::info!("Creating a new project");
+ // check that the path does not exists or is empty
+ if path.exists() && path.read_dir().map_err(Error::from)?.next().is_some() {
+ return Err(Error::ProjectPathNotEmpty);
+ }
+
+ template::clone_template(template, path).await?;
+
+ // create `.template` file to save template repo url and commit
+ let git_dir = path.join(".git");
+ let commit = git::get_current_commit(Some(git_dir.clone())).await?;
+ TemplateData {
+ template: template.to_string(),
+ commit: Some(commit.clone()),
+ }
+ .save_to(&path.join(template::TEMPLATE_DATA_FILE))
+ .await
+ .map_err(Error::from)?;
+
+ create_cache_directories(path, &git_dir)?;
+
+ log::info!("Project created");
+ Ok(())
+}
+
+#[cfg_attr(test, mockable)]
+fn create_cache_directories(path: &PathBuf, git_dir: &PathBuf) -> Result<()> {
+ log::debug!("create cache directories");
+ // create the `.lenra` cache directory
+ let cache_dir = path.join(LENRA_CACHE_DIRECTORY);
+ fs::create_dir_all(cache_dir.clone()).unwrap();
+ // move the template `.git` directory
+ fs::rename(git_dir, cache_dir.join(template::TEMPLATE_GIT_DIR))?;
+
+ log::info!("Project created");
+ Ok(())
+}
+
+pub async fn generate_app_env(context: &mut CommandContext, production: bool) -> Result<()> {
+ log::info!("Generating the app environment");
+ let conf = context
+ .config
+ .clone()
+ .ok_or(Error::Custom("The config is missing".into()))?;
+ // TODO: check the components API version
+
+ conf.generate_files(context, !production).await?;
+ Ok(())
+}
+
+pub async fn build_app(context: &mut CommandContext) -> Result<()> {
+ log::info!("Build the Docker image");
+ compose_build(context).await?;
+ log::info!("Image built");
+ Ok(())
+}
+
+pub async fn start_env(context: &mut CommandContext) -> Result<()> {
+ let dockercompose_path: PathBuf =
+ context.resolve_path(&DOCKERCOMPOSE_DEFAULT_PATH.iter().collect());
+ if !dockercompose_path.exists() {
+ return Err(Error::NeverBuiltApp);
+ }
+
+ log::info!("Start the containers");
+ compose_up(context).await?;
+ let running_services: Vec = list_running_services(context).await?;
+ if running_services.len() < 4 {
+ return Err(Error::NotStartedServices);
+ }
+ Ok(())
+}
+
+pub async fn stop_env(context: &mut CommandContext) -> Result<()> {
+ log::info!("Stop the containers");
+ compose_down(context).await?;
+ Ok(())
+}
+
+pub async fn clear_cache(context: &mut CommandContext) -> Result<()> {
+ log::info!("Clearing cache");
+ stop_app_env(context).await?;
+ Ok(())
+}
+
+pub fn display_app_access_url() {
+ println!(
+ "\nApplication available at http://localhost:{}\n",
+ docker_compose::DEVTOOL_WEB_PORT
+ );
+}
+
+pub async fn update_env_images(
+ context: &mut CommandContext,
+ services: &Vec,
+) -> Result<()> {
+ log::info!("Update the environment images");
+ docker_compose::compose_pull(context, services).await?;
+ Ok(())
+}
+
+pub async fn upgrade_app() -> Result<()> {
+ log::info!("Upgrading the application");
+ // get template data
+ let template_data = template::get_template_data().await?;
+ let git_dir = Path::new(LENRA_CACHE_DIRECTORY).join(template::TEMPLATE_GIT_DIR);
+
+ if git_dir.is_dir() {
+ // update the template repo
+ git::pull(Some(git_dir.clone())).await?;
+ } else {
+ let template_tmp = Path::new(LENRA_CACHE_DIRECTORY).join(template::TEMPLATE_TEMP_DIR);
+ // clone template project
+ template::clone_template(template_data.template.as_str(), &template_tmp).await?;
+ fs::rename(template_tmp.join(".git"), git_dir.clone())?;
+ fs::remove_dir_all(template_tmp)?;
+ }
+
+ let current_commit = git::get_current_commit(Some(git_dir.clone())).await?;
+ if let Some(commit) = template_data.commit {
+ if commit == current_commit {
+ println!("This application is already up to date");
+ return Ok(());
+ }
+
+ // get diff between previous commit and current commit
+ let patch_file = Path::new(LENRA_CACHE_DIRECTORY)
+ .join(format!("patch.{}-{}.diff", commit, current_commit));
+ log::debug!(
+ "create patch between {} and {}: {:?}",
+ commit,
+ current_commit,
+ patch_file
+ );
+ let mut cmd = git::create_git_command();
+ cmd.arg("--git-dir")
+ .arg(git_dir.as_os_str())
+ .arg("diff")
+ .arg(commit)
+ .arg(current_commit.clone());
+ let mut patch = get_command_output(cmd).await?;
+ patch.push('\n');
+ fs::write(patch_file.clone(), patch)?;
+
+ // apply a patch
+ log::debug!("apply patch on project");
+ let mut cmd = git::create_git_command();
+ cmd.arg("apply")
+ .arg(patch_file.clone())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit());
+ let patch_file_str = patch_file.to_string_lossy();
+ while !cmd.spawn()?.wait_with_output().await?.status.success() {
+ println!("An error occured applying the patch {patch_file_str}");
+ let mut rl = Editor::<()>::new()?;
+ rl.readline("Fix it and press enter to retry")?;
+ }
+ fs::remove_file(patch_file)?;
+ } else {
+ // ask for user confirmation
+ if !confirm_checkout()? {
+ println!("Upgrade canceled");
+ return Ok(());
+ }
+
+ // checkout the template in the current dir
+ log::debug!("checkout the template");
+ let mut cmd = git::create_git_command();
+ cmd.arg("--git-dir")
+ .arg(git_dir.as_os_str())
+ .arg("checkout")
+ .arg("HEAD")
+ .arg("--")
+ .arg(".");
+ cmd.spawn()?.wait_with_output().await.map_err(Error::from)?;
+ }
+ // save template data
+ TemplateData {
+ template: template_data.template,
+ commit: Some(current_commit),
+ }
+ .save()
+ .await
+}
+
+fn confirm_checkout() -> Result {
+ let mut rl = Editor::<()>::new()?;
+ println!("There is no template last commit in this project, the template files will checked out to your app.\nMake sure your project is saved (for example with git).");
+ loop {
+ let res = rl
+ .readline("Checkout the template ? [y/N] ")?
+ .trim()
+ .to_lowercase();
+ if res == "y" || res == "yes" {
+ return Ok(true);
+ } else if res.is_empty() || res == "n" || res == "no" {
+ return Ok(false);
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..e1d10b4
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,47 @@
+//! # lenra_cli
+//!
+//! The Lenra's command line interface
+
+use clap::Parser;
+use cli::{terminal::start_terminal, Cli, CliCommand, CommandContext};
+use env_logger;
+
+mod cli;
+mod command;
+mod config;
+mod devtool;
+mod docker;
+mod docker_compose;
+mod errors;
+mod git;
+mod github;
+mod keyboard_event;
+mod lenra;
+mod matching;
+mod template;
+
+#[tokio::main]
+async fn main() -> () {
+ env_logger::init();
+ let args = Cli::parse();
+ let context = &mut CommandContext {
+ config_path: args.config,
+ config: None,
+ expose: args.expose,
+ verbose: args.verbose,
+ };
+ if args.verbose {
+ command::set_inherit_stdio(true);
+ }
+ let res = match args.command {
+ Some(command) => command.run(context).await,
+ None => start_terminal(context).await,
+ };
+ match res {
+ Ok(_) => (),
+ Err(e) => {
+ println!("{}", e.to_string());
+ std::process::exit(1);
+ }
+ }
+}
diff --git a/src/matching.rs b/src/matching.rs
new file mode 100644
index 0000000..1d76f57
--- /dev/null
+++ b/src/matching.rs
@@ -0,0 +1,173 @@
+use serde_json::Value;
+
+#[derive(Clone)]
+pub enum MatchingErrorType {
+ NotSameType { actual: Value, expected: Value },
+ NotSameValue { actual: Value, expected: Value },
+ AdditionalProperty,
+ MissingProperty,
+}
+
+#[derive(Clone)]
+pub struct MatchingError {
+ pub path: String,
+ pub error_type: MatchingErrorType,
+}
+
+pub trait Matching {
+ fn match_type(&self, val: &Value) -> bool;
+ fn check_match(&self, expected: &Value) -> Vec;
+ fn type_name(&self) -> &str;
+}
+
+impl Matching for Value {
+ fn match_type(&self, val: &Value) -> bool {
+ match self {
+ Value::Null => val.is_null(),
+ Value::Bool(_) => val.is_boolean(),
+ Value::Number(_) => val.is_number(),
+ Value::String(_) => val.is_string(),
+ Value::Array(_) => val.is_array(),
+ Value::Object(_) => val.is_object(),
+ }
+ }
+
+ fn check_match(&self, expected: &Value) -> Vec {
+ if expected == self {
+ return vec![];
+ }
+ if !self.match_type(expected) {
+ return vec![MatchingError {
+ path: "".into(),
+ error_type: MatchingErrorType::NotSameType {
+ actual: self.clone(),
+ expected: expected.clone(),
+ },
+ }];
+ }
+
+ match self {
+ Value::Array(array) => {
+ let expected_array = expected.as_array().unwrap();
+ let mut ret: Vec = vec![];
+ let common_length = if array.len() > expected_array.len() {
+ expected_array.len()
+ } else {
+ array.len()
+ };
+
+ for i in 0..common_length {
+ let v = array.get(i).unwrap();
+ let expected_v = expected_array.get(i).unwrap();
+ v.check_match(expected_v)
+ .iter()
+ .map(|error| MatchingError {
+ path: if error.path.is_empty() {
+ format!("{}", i)
+ } else {
+ format!("{}.{}", i, error.path)
+ },
+ error_type: error.error_type.clone(),
+ })
+ .for_each(|error| ret.push(error));
+ }
+ for i in common_length..array.len() {
+ ret.push(MatchingError {
+ path: format!("{}", i),
+ error_type: MatchingErrorType::AdditionalProperty,
+ });
+ }
+ for i in common_length..expected_array.len() {
+ ret.push(MatchingError {
+ path: format!("{}", i),
+ error_type: MatchingErrorType::MissingProperty,
+ });
+ }
+
+ ret
+ }
+ Value::Object(object) => {
+ let expected_object = expected.as_object().unwrap();
+ let keys = object.keys();
+ let expected_keys = expected_object.keys();
+ let mut ret: Vec = vec![];
+
+ expected_keys.for_each(|key| {
+ if object.contains_key(key) {
+ let value = object.get(key).unwrap();
+ let expected_value = expected_object.get(key).unwrap();
+ value
+ .check_match(expected_value)
+ .iter()
+ .map(|error| MatchingError {
+ path: if error.path.is_empty() {
+ key.into()
+ } else {
+ format!("{}.{}", key, error.path)
+ },
+ error_type: error.error_type.clone(),
+ })
+ .for_each(|error| ret.push(error));
+ } else {
+ ret.push(MatchingError {
+ path: key.into(),
+ error_type: MatchingErrorType::MissingProperty,
+ });
+ }
+ });
+
+ keys.for_each(|key| {
+ if !expected_object.contains_key(key) {
+ ret.push(MatchingError {
+ path: key.into(),
+ error_type: MatchingErrorType::AdditionalProperty,
+ });
+ }
+ });
+
+ ret
+ }
+ Value::Number(number) => {
+ let result = if number.is_f64() || expected.is_f64() {
+ number.as_f64().unwrap().eq(&expected.as_f64().unwrap())
+ } else if number.is_i64() || expected.is_i64() {
+ number.as_i64().unwrap().eq(&expected.as_i64().unwrap())
+ } else {
+ number.as_u64().unwrap().eq(&expected.as_u64().unwrap())
+ };
+
+ if !result {
+ vec![MatchingError {
+ path: "".into(),
+ error_type: MatchingErrorType::NotSameValue {
+ actual: self.clone(),
+ expected: expected.clone(),
+ },
+ }]
+ } else {
+ vec![]
+ }
+ }
+ Value::Null => panic!("Should not be reached"),
+ // Since equality have been tested before
+ _ => vec![MatchingError {
+ path: "".into(),
+ error_type: MatchingErrorType::NotSameValue {
+ actual: self.clone(),
+ expected: expected.clone(),
+ },
+ }],
+ }
+ }
+
+ fn type_name(&self) -> &str {
+ match self {
+ Value::Null => "null",
+ Value::Bool(_) => "bool",
+ Value::Number(_) => "number",
+ Value::String(_) => "string",
+ Value::Array(_) => "array",
+ Value::Object(_) => "object",
+ }
+ }
+}
diff --git a/src/template.rs b/src/template.rs
new file mode 100644
index 0000000..032cd21
--- /dev/null
+++ b/src/template.rs
@@ -0,0 +1,163 @@
+use std::{
+ collections::HashMap,
+ fs,
+ path::{Path, PathBuf},
+};
+
+use crate::{
+ command::{get_command_output, run_command},
+ config::LENRA_CACHE_DIRECTORY,
+ errors::{Error, Result},
+ git::{create_git_command, Repository},
+ github::{search_repositories, GITHUB_TOPIC_REGEX},
+};
+use lazy_static::lazy_static;
+use log;
+use regex::Regex;
+use rustyline::Editor;
+
+#[cfg(test)]
+use mocktopus::macros::mockable;
+
+pub const TEMPLATE_DATA_FILE: &str = ".template";
+pub const TEMPLATE_GIT_DIR: &str = "template.git";
+pub const TEMPLATE_TEMP_DIR: &str = "template.tmp";
+pub const RL_CHOOSE_TEMPLATE_MSG: &str = "Which template do you want to use ? ";
+
+lazy_static! {
+ static ref TEMPLATE_ALIASES: HashMap<&'static str, &'static str> = vec![
+ ("js", "javascript"),
+ ("ts", "typescript"),
+ ("rs", "rust"),
+ ("py", "python"),
+ ]
+ .into_iter()
+ .collect();
+}
+
+lazy_static! {
+ pub static ref TEMPLATE_SHORT_REGEX: Regex =
+ Regex::new(r"^(template-)?([0-9a-zA-Z]+([_-][0-9a-zA-Z]+)*)$").unwrap();
+}
+
+pub struct TemplateData {
+ pub template: String,
+ pub commit: Option,
+}
+
+#[cfg_attr(test, mockable)]
+impl TemplateData {
+ pub async fn save(&self) -> Result<()> {
+ let path = Path::new(TEMPLATE_DATA_FILE);
+ self.save_to(&path).await
+ }
+
+ pub async fn save_to(&self, path: &Path) -> Result<()> {
+ let commit = self.commit.clone().unwrap();
+ log::debug!("save template data {}:{}", self.template, commit);
+ fs::write(path, format!("{}\n{}", self.template, commit)).map_err(Error::from)
+ }
+}
+
+pub fn normalize_template(template: String) -> String {
+ if TEMPLATE_SHORT_REGEX.is_match(template.as_str()) {
+ // Replace aliases
+ let &name = TEMPLATE_ALIASES
+ .get(template.as_str())
+ .unwrap_or(&template.as_str());
+
+ format!(
+ "https://github.com/lenra-io/template-{}",
+ TEMPLATE_SHORT_REGEX.replace(name, "$2")
+ )
+ } else {
+ template.clone()
+ }
+}
+
+#[cfg_attr(test, mockable)]
+pub async fn list_templates(topics: &Vec) -> Result> {
+ let mut query: String = String::from("topic:lenra+topic:template");
+ for topic in topics {
+ // check topic format
+ if !GITHUB_TOPIC_REGEX.is_match(topic.as_str()) {
+ return Err(Error::InvalidGitHubTopic(topic.clone()));
+ }
+ query.push_str(format!("+topic:{}", topic).as_str());
+ }
+ search_repositories(query.as_str()).await
+}
+
+#[cfg_attr(test, mockable)]
+pub async fn choose_repository(repos: Vec) -> Result {
+ let mut rl = Editor::<()>::new()?;
+ let mut index = 0;
+ let mut max_index = 0;
+ for repo in &repos {
+ println!(
+ "{:5} {} ({} stars) - {}",
+ format!("[{}]:", index + 1),
+ repo.name,
+ repo.stars,
+ repo.description
+ );
+ index += 1;
+ max_index = index;
+ }
+ let mut choice = rl.readline(RL_CHOOSE_TEMPLATE_MSG)?;
+ while choice.parse::().is_err()
+ || choice.parse::().unwrap() < 1
+ || choice.parse::().unwrap() > max_index
+ {
+ choice = rl.readline(RL_CHOOSE_TEMPLATE_MSG)?;
+ }
+ Ok(repos[choice.parse::().unwrap() - 1].clone())
+}
+
+#[cfg_attr(test, mockable)]
+pub async fn clone_template(template: &str, target_dir: &PathBuf) -> Result<()> {
+ log::debug!(
+ "clone the template {} into {}",
+ template,
+ target_dir.display()
+ );
+ let mut cmd = create_git_command();
+ cmd.arg("clone").arg(template).arg(target_dir.as_os_str());
+ run_command(cmd).await?;
+
+ Ok(())
+}
+
+#[cfg_attr(test, mockable)]
+pub async fn get_template_data() -> Result {
+ let template_data_file = Path::new(TEMPLATE_DATA_FILE);
+ let git_dir = Path::new(LENRA_CACHE_DIRECTORY).join(TEMPLATE_GIT_DIR);
+ if template_data_file.exists() {
+ let template_data = fs::read_to_string(template_data_file).map_err(Error::from)?;
+ let template_data: Vec<&str> = template_data.split("\n").collect();
+ Ok(TemplateData {
+ template: template_data[0].into(),
+ commit: Some(template_data[1].into()),
+ })
+ } else if git_dir.exists() {
+ let mut cmd = create_git_command();
+ cmd.arg("--git-dir")
+ .arg(git_dir.as_os_str())
+ .arg("config")
+ .arg("--get")
+ .arg("remote.origin.url");
+ let template = get_command_output(cmd).await?;
+ Ok(TemplateData {
+ template,
+ commit: None,
+ })
+ } else {
+ let mut rl = Editor::<()>::new()?;
+ println!("The '.template' file does not exist.");
+ let template = normalize_template(rl.readline("What is the template to use ? ")?);
+ Ok(TemplateData {
+ template,
+ commit: None,
+ })
+ }
+}
diff --git a/test/config/app_path.yml b/test/config/app_path.yml
new file mode 100644
index 0000000..a489dd1
--- /dev/null
+++ b/test/config/app_path.yml
@@ -0,0 +1,4 @@
+path: test_app
+generator:
+ dofigen:
+ from: scratch
\ No newline at end of file