diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 70444a1..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: ptdewey diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yaml similarity index 87% rename from .github/workflows/docs.yml rename to .github/workflows/docs.yaml index f4ebc7d..ef089c8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yaml @@ -14,13 +14,13 @@ jobs: uses: kdheepak/panvimdoc@main with: vimdoc: pendulum-nvim - version: "Neovim >= 0.10.0" + version: "Neovim >= 0.11.0" demojify: true treesitter: true - name: Push changes uses: stefanzweifel/git-auto-commit-action@v4 with: - commit_message: "doc: auto-generate vimdoc" + commit_message: "docs: auto-generate vimdoc" commit_user_name: "github-actions[bot]" commit_user_email: "github-actions[bot]@users.noreply.github.com" commit_author: "github-actions[bot] " diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..044e9c8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,22 @@ +name: Release To GitHub + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + release: + name: Release To GitHub + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + release-type: simple diff --git a/.gitignore b/.gitignore index 77782a4..e7cca9d 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,6 @@ luac.out ## Extras remote/pendulum-nvim +pendulum-server +bin/ .luarc.json diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..f0f936f --- /dev/null +++ b/BACKLOG.md @@ -0,0 +1,8 @@ +# FIX + +- Logs twice when switching to new buffer (new buffer is logged twice) + +# TODO + +- Document v1/v2 differences, how to use v1 if want to stay + - v1 will be tagged in git diff --git a/Makefile b/Makefile deleted file mode 100644 index 1764a0e..0000000 --- a/Makefile +++ /dev/null @@ -1,11 +0,0 @@ -fmt: - echo "Formatting lua/yankbank..." - stylua lua/ --config-path=.stylua.toml - echo "Formatting Go files in ./remote..." - find ./remote -name '*.go' -exec gofmt -w {} + - -lint: - echo "Linting lua/yankbank..." - luacheck lua/ --globals vim - -pr-ready: fmt lint diff --git a/README.md b/README.md index 776d1fb..f4a1a19 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ The metrics report contents are customizable and section items or entire section These are some potential future ideas that would make for welcome contributions for anyone interested. - Logging to SQLite database (optionally) -- Telescope integration +- Fuzzy finder integration - Get stats for specified project, filetype, etc. (Could work well with Telescope) - Nicer looking popup with custom highlight groups - Alternative version of popup that uses a terminal buffer and [bubbletea](https://github.com/charmbracelet/bubbletea) (using the table component) diff --git a/doc/pendulum-nvim.txt b/doc/pendulum-nvim.txt index d88b3cd..9a00e58 100644 --- a/doc/pendulum-nvim.txt +++ b/doc/pendulum-nvim.txt @@ -1,267 +1,267 @@ -*pendulum-nvim.txt* For Neovim >= 0.10.0 Last change: 2025 June 25 - -============================================================================== -Table of Contents *pendulum-nvim-table-of-contents* - -1. Pendulum-nvim |pendulum-nvim-pendulum-nvim| - - Motivation |pendulum-nvim-pendulum-nvim-motivation| - - What it Does |pendulum-nvim-pendulum-nvim-what-it-does| - - Installation |pendulum-nvim-pendulum-nvim-installation| - - Configuration |pendulum-nvim-pendulum-nvim-configuration| - - Usage |pendulum-nvim-pendulum-nvim-usage| - - Report Generation |pendulum-nvim-pendulum-nvim-report-generation| - - Future Ideas |pendulum-nvim-pendulum-nvim-future-ideas| -2. Links |pendulum-nvim-links| - -============================================================================== -1. Pendulum-nvim *pendulum-nvim-pendulum-nvim* - -Pendulum is a Neovim plugin designed for tracking time spent on projects within -Neovim. It logs various events like entering and leaving buffers and idle times -into a CSV file, making it easy to analyze your coding activity over time. - -Pendulum also includes a user command that aggregates log information into a -popup report viewable within your editor - - -MOTIVATION *pendulum-nvim-pendulum-nvim-motivation* - -Pendulum was created to offer a privacy-focused alternative to cloud-based time -tracking tools, addressing concerns about data security and ownership. This -"local-first" tool ensures all data stays on the user’s machine, providing -full control and customization without requiring internet access. It’s -designed for developers who prioritize privacy and autonomy but are curious -about how they spend their time. - - -WHAT IT DOES *pendulum-nvim-pendulum-nvim-what-it-does* - -- Automatic Time Tracking: Logs time spent in each file along with the workding directory, file type, project name, and git branch if available. -- Activity Detection: Detects user activity based on cursor movements (on a timer) and buffer switches. -- Customizable Timeout: Configurable timeout to define user inactivity. -- Event Logging: Tracks buffer events and idle periods, writing these to a CSV log for later analysis. -- Report Generation: Generate reports from the log file to quickly view how time was spent on various projects (requires Go installed). - - -INSTALLATION *pendulum-nvim-pendulum-nvim-installation* - -Install Pendulum using your favorite package manager: - - -WITH REPORT GENERATION (REQUIRES GO) - -With lazy.nvim - ->lua - { - "ptdewey/pendulum-nvim", - config = function() - require("pendulum").setup() - end, - } -< - - -WITHOUT REPORT GENERATION - -With lazy.nvim - ->lua - { - "ptdewey/pendulum-nvim", - config = function() - require("pendulum").setup({ - gen_reports = false, - }) - end, - } -< - - -CONFIGURATION *pendulum-nvim-pendulum-nvim-configuration* - -Pendulum can be customized with several options. Here is a table with -configurable options: - - --------------------------------------------------------------------------------------- - Option Description Default - ------------------------- ------------------------------------ ------------------------ - log_file Path to the CSV file where logs $HOME/pendulum-log.csv - should be written - - timeout_len Length of time in seconds to 180 - determine inactivity - - timer_len Interval in seconds at which to 120 - check activity - - gen_reports Generate reports from the log file true - - top_n Number of top entries to include in 5 - the report - - hours_n Number of entries to show in active 10 - hours report - - time_format Use 12/24 hour time format for 12h - active hours report (possible - values: 12h, 24h) - - time_zone Time zone for use in active hour UTC - calculations (format: - America/New_York) - - report_section_excludes Additional filters to be applied to {} - each report section - - report_excludes Show/Hide report sections. e.g {} - branch, directory, file, filetype, - project - --------------------------------------------------------------------------------------- -Default configuration - ->lua - require("pendulum").setup({ - log_file = vim.env.HOME .. "/pendulum-log.csv", - timeout_len = 180, - timer_len = 120, - gen_reports = true, - top_n = 5, - hours_n = 10, - time_format = "12h", - time_zone = "UTC", -- Format "America/New_York" - report_excludes = { - branch = {}, - directory = {}, - file = {}, - filetype = {}, - project = {}, - }, - report_section_excludes = {}, - }) -< - -Example configuration with custom options: (Note: this is not the recommended -configuration, but just shows potential options) - ->lua - require('pendulum').setup({ - log_file = vim.fn.expand("$HOME/Documents/my_custom_log.csv"), - timeout_len = 300, -- 5 minutes - timer_len = 60, -- 1 minute - gen_reports = true, -- Enable report generation (requires Go) - top_n = 10, -- Include top 10 entries in the report - hours_n = 10, - time_format = "12h", - time_zone = "America/New_York", - report_section_excludes = { - "branch", -- Hide `branch` section of the report - -- Other options includes: - -- "directory", - -- "filetype", - -- "file", - -- "project", - }, - report_excludes = { - filetype = { - -- This table controls what to be excluded from `filetype` section - "neo-tree", -- Exclude neo-tree filetype - }, - file = { - -- This table controls what to be excluded from `file` section - "test.py", -- Exclude any test.py - ".*.go", -- Exclude all Go files - } - project = { - -- This table controls what to be excluded from `project` section - "unknown_project" -- Exclude unknown (non-git) projects - }, - directory = { - -- This table controls what to be excluded from `directory` section - }, - branch = { - -- This table controls what to be excluded from `branch` section - }, - }, - }) -< - -**Note**You can use regex to express the matching patterns within -`report_excludes`. - - -USAGE *pendulum-nvim-pendulum-nvim-usage* - -Once configured, Pendulum runs automatically in the background. It logs each -specified event into the CSV file, which includes timestamps, file names, -project names (from Git), and activity states. - -The CSV log file will have the columns: `time`, `active`, `file`, `filetype`, -`cwd`, `project`, and `branch`. - - -REPORT GENERATION *pendulum-nvim-pendulum-nvim-report-generation* - -Pendulum can generate detailed reports from the log file. To use this feature, -you need to have Go installed on your system. The report includes the top -entries based on the time spent on various projects. - -To rebuild the Pendulum binary and generate reports, use the following -commands: - ->vim - :PendulumRebuild - :Pendulum - :PendulumHours -< - -The `:PendulumRebuild` command recompiles the Go binary, and the :Pendulum -command generates the report based on the current log file. I recommend -rebuilding the binary after the plugin is updated. - -The `:Pendulum` command generates and shows the metrics view (i.e. time spent -per branch, project, filetype, etc.). Report generation will take longer as the -size of your log file increases. - -The `:PendulumHours` command generates and shows the active hours view, which -shows which times of day you are most active (and time spent). - -If you do not want to install Go, report generation can be disabled by changing -the `gen_reports` option to `false`. Disabling reports will cause the -`Pendulum`, `PendulumHours`, and `PendulumRebuild` commands to not be created -since they are exclusively used for the reports feature. - ->lua - config = function() - require("pendulum").setup({ - -- disable report generations (avoids Go dependency) - gen_reports = false, - }) - end, -< - -The metrics report contents are customizable and section items or entire -sections can be excluded from the report if desired. (See `report_excludes` and -`report_section_excludes` options in setup) - - -FUTURE IDEAS *pendulum-nvim-pendulum-nvim-future-ideas* - -These are some potential future ideas that would make for welcome contributions -for anyone interested. - -- Logging to SQLite database (optionally) -- Telescope integration -- Get stats for specified project, filetype, etc. (Could work well with Telescope) -- Nicer looking popup with custom highlight groups -- Alternative version of popup that uses a terminal buffer and bubbletea (using the table component) - -============================================================================== -2. Links *pendulum-nvim-links* - -1. *Pendulum Metrics View*: ./assets/screenshot0.png -2. *Pendulum Active Hours View*: ./assets/screenshot1.png - -Generated by panvimdoc - -vim:tw=78:ts=8:noet:ft=help:norl: ++*pendulum-nvim.txt* For Neovim >= 0.10.0 Last change: 2025 June 25 + + ============================================================================== + Table of Contents *pendulum-nvim-table-of-contents* + + 1. Pendulum-nvim |pendulum-nvim-pendulum-nvim| + - Motivation |pendulum-nvim-pendulum-nvim-motivation| + - What it Does |pendulum-nvim-pendulum-nvim-what-it-does| + - Installation |pendulum-nvim-pendulum-nvim-installation| + - Configuration |pendulum-nvim-pendulum-nvim-configuration| + - Usage |pendulum-nvim-pendulum-nvim-usage| + - Report Generation |pendulum-nvim-pendulum-nvim-report-generation| + - Future Ideas |pendulum-nvim-pendulum-nvim-future-ideas| + 2. Links |pendulum-nvim-links| + + ============================================================================== + 1. Pendulum-nvim *pendulum-nvim-pendulum-nvim* + + Pendulum is a Neovim plugin designed for tracking time spent on projects within + Neovim. It logs various events like entering and leaving buffers and idle times + into a CSV file, making it easy to analyze your coding activity over time. + + Pendulum also includes a user command that aggregates log information into a + popup report viewable within your editor + + + MOTIVATION *pendulum-nvim-pendulum-nvim-motivation* + + Pendulum was created to offer a privacy-focused alternative to cloud-based time + tracking tools, addressing concerns about data security and ownership. This + "local-first" tool ensures all data stays on the user’s machine, providing + full control and customization without requiring internet access. It’s + designed for developers who prioritize privacy and autonomy but are curious + about how they spend their time. + + + WHAT IT DOES *pendulum-nvim-pendulum-nvim-what-it-does* + + - Automatic Time Tracking: Logs time spent in each file along with the workding directory, file type, project name, and git branch if available. + - Activity Detection: Detects user activity based on cursor movements (on a timer) and buffer switches. + - Customizable Timeout: Configurable timeout to define user inactivity. + - Event Logging: Tracks buffer events and idle periods, writing these to a CSV log for later analysis. + - Report Generation: Generate reports from the log file to quickly view how time was spent on various projects (requires Go installed). + + + INSTALLATION *pendulum-nvim-pendulum-nvim-installation* + + Install Pendulum using your favorite package manager: + + + WITH REPORT GENERATION (REQUIRES GO) + + With lazy.nvim + + >lua + { + "ptdewey/pendulum-nvim", + config = function() + require("pendulum").setup() + end, + } + < + + + WITHOUT REPORT GENERATION + + With lazy.nvim + + >lua + { + "ptdewey/pendulum-nvim", + config = function() + require("pendulum").setup({ + gen_reports = false, + }) + end, + } + < + + + CONFIGURATION *pendulum-nvim-pendulum-nvim-configuration* + + Pendulum can be customized with several options. Here is a table with + configurable options: + + --------------------------------------------------------------------------------------- + Option Description Default + ------------------------- ------------------------------------ ------------------------ + log_file Path to the CSV file where logs $HOME/pendulum-log.csv + should be written + + timeout_len Length of time in seconds to 180 + determine inactivity + + timer_len Interval in seconds at which to 120 + check activity + + gen_reports Generate reports from the log file true + + top_n Number of top entries to include in 5 + the report + + hours_n Number of entries to show in active 10 + hours report + + time_format Use 12/24 hour time format for 12h + active hours report (possible + values: 12h, 24h) + + time_zone Time zone for use in active hour UTC + calculations (format: + America/New_York) + + report_section_excludes Additional filters to be applied to {} + each report section + + report_excludes Show/Hide report sections. e.g {} + branch, directory, file, filetype, + project + --------------------------------------------------------------------------------------- + Default configuration + + >lua + require("pendulum").setup({ + log_file = vim.env.HOME .. "/pendulum-log.csv", + timeout_len = 180, + timer_len = 120, + gen_reports = true, + top_n = 5, + hours_n = 10, + time_format = "12h", + time_zone = "UTC", -- Format "America/New_York" + report_excludes = { + branch = {}, + directory = {}, + file = {}, + filetype = {}, + project = {}, + }, + report_section_excludes = {}, + }) + < + + Example configuration with custom options: (Note: this is not the recommended + configuration, but just shows potential options) + + >lua + require('pendulum').setup({ + log_file = vim.fn.expand("$HOME/Documents/my_custom_log.csv"), + timeout_len = 300, -- 5 minutes + timer_len = 60, -- 1 minute + gen_reports = true, -- Enable report generation (requires Go) + top_n = 10, -- Include top 10 entries in the report + hours_n = 10, + time_format = "12h", + time_zone = "America/New_York", + report_section_excludes = { + "branch", -- Hide `branch` section of the report + -- Other options includes: + -- "directory", + -- "filetype", + -- "file", + -- "project", + }, + report_excludes = { + filetype = { + -- This table controls what to be excluded from `filetype` section + "neo-tree", -- Exclude neo-tree filetype + }, + file = { + -- This table controls what to be excluded from `file` section + "test.py", -- Exclude any test.py + ".*.go", -- Exclude all Go files + } + project = { + -- This table controls what to be excluded from `project` section + "unknown_project" -- Exclude unknown (non-git) projects + }, + directory = { + -- This table controls what to be excluded from `directory` section + }, + branch = { + -- This table controls what to be excluded from `branch` section + }, + }, + }) + < + + **Note**You can use regex to express the matching patterns within + `report_excludes`. + + + USAGE *pendulum-nvim-pendulum-nvim-usage* + + Once configured, Pendulum runs automatically in the background. It logs each + specified event into the CSV file, which includes timestamps, file names, + project names (from Git), and activity states. + + The CSV log file will have the columns: `time`, `active`, `file`, `filetype`, + `cwd`, `project`, and `branch`. + + + REPORT GENERATION *pendulum-nvim-pendulum-nvim-report-generation* + + Pendulum can generate detailed reports from the log file. To use this feature, + you need to have Go installed on your system. The report includes the top + entries based on the time spent on various projects. + + To rebuild the Pendulum binary and generate reports, use the following + commands: + + >vim + :PendulumRebuild + :Pendulum + :PendulumHours + < + + The `:PendulumRebuild` command recompiles the Go binary, and the :Pendulum + command generates the report based on the current log file. I recommend + rebuilding the binary after the plugin is updated. + + The `:Pendulum` command generates and shows the metrics view (i.e. time spent + per branch, project, filetype, etc.). Report generation will take longer as the + size of your log file increases. + + The `:PendulumHours` command generates and shows the active hours view, which + shows which times of day you are most active (and time spent). + + If you do not want to install Go, report generation can be disabled by changing + the `gen_reports` option to `false`. Disabling reports will cause the + `Pendulum`, `PendulumHours`, and `PendulumRebuild` commands to not be created + since they are exclusively used for the reports feature. + + >lua + config = function() + require("pendulum").setup({ + -- disable report generations (avoids Go dependency) + gen_reports = false, + }) + end, + < + + The metrics report contents are customizable and section items or entire + sections can be excluded from the report if desired. (See `report_excludes` and + `report_section_excludes` options in setup) + + + FUTURE IDEAS *pendulum-nvim-pendulum-nvim-future-ideas* + + These are some potential future ideas that would make for welcome contributions + for anyone interested. + + - Logging to SQLite database (optionally) + - Telescope integration + - Get stats for specified project, filetype, etc. (Could work well with Telescope) + - Nicer looking popup with custom highlight groups + - Alternative version of popup that uses a terminal buffer and bubbletea (using the table component) + + ============================================================================== + 2. Links *pendulum-nvim-links* + + 1. *Pendulum Metrics View*: ./assets/screenshot0.png + 2. *Pendulum Active Hours View*: ./assets/screenshot1.png + + Generated by panvimdoc + + vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9e99ed4 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/ptdewey/pendulum-server + +go 1.24.4 + +require ( + github.com/ptdewey/shutter v0.1.4 + github.com/tliron/commonlog v0.2.8 + github.com/tliron/glsp v0.2.2 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect + github.com/kortschak/utter v1.7.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sasha-s/go-deadlock v0.3.1 // indirect + github.com/sourcegraph/jsonrpc2 v0.2.0 // indirect + github.com/tliron/kutil v0.3.11 // indirect + golang.org/x/crypto v0.15.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.14.0 // indirect + golang.org/x/term v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..61eefd9 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/kortschak/utter v1.7.0 h1:6NKMynvGUyqfeMTawfah4zyInlrgwzjkDAHrT+skx/w= +github.com/kortschak/utter v1.7.0/go.mod h1:vSmSjbyrlKjjsL71193LmzBOKgwePk9DH6uFaWHIInc= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= +github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/ptdewey/shutter v0.1.4 h1:tMTNMTxCpA1F0REyi+taztoHVe9EpB5sSKhaIBzYu1c= +github.com/ptdewey/shutter v0.1.4/go.mod h1:teeIXF4LdgsE9E4kjHk9nGzDxl2cjdbVb1qbdzAHSR4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= +github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= +github.com/sourcegraph/jsonrpc2 v0.2.0 h1:KjN/dC4fP6aN9030MZCJs9WQbTOjWHhrtKVpzzSrr/U= +github.com/sourcegraph/jsonrpc2 v0.2.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo= +github.com/tliron/commonlog v0.2.8 h1:vpKrEsZX4nlneC9673pXpeKqv3cFLxwpzNEZF1qiaQQ= +github.com/tliron/commonlog v0.2.8/go.mod h1:HgQZrJEuiKLLRvUixtPWGcmTmWWtKkCtywF6x9X5Spw= +github.com/tliron/glsp v0.2.2 h1:IKPfwpE8Lu8yB6Dayta+IyRMAbTVunudeauEgjXBt+c= +github.com/tliron/glsp v0.2.2/go.mod h1:GMVWDNeODxHzmDPvYbYTCs7yHVaEATfYtXiYJ9w1nBg= +github.com/tliron/kutil v0.3.11 h1:kongR0dhrrn9FR/3QRFoUfQe27t78/xQvrU9aXIy5bk= +github.com/tliron/kutil v0.3.11/go.mod h1:4IqOAAdpJuDxYbJxMv4nL8LSH0mPofSrdwIv8u99PDc= +golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..778a68e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,81 @@ +package config + +import ( + "fmt" + "os" + "time" +) + +var cfg *config + +type config struct { + LogFile string + LspLogFile string + Debug bool + TimeoutLen time.Duration + TimerLen time.Duration +} + +type option func(c *config) + +func WithActivityFile(path string) option { + return func(c *config) { + c.LogFile = path + } +} + +func WithLogFile(path string) option { + return func(c *config) { + c.LspLogFile = path + } +} + +func WithDebug(debug bool) option { + return func(c *config) { + c.Debug = debug + } +} + +func WithTimeoutLen(timeout time.Duration) option { + return func(c *config) { + c.TimeoutLen = timeout + } +} + +func WithTimerLen(timer time.Duration) option { + return func(c *config) { + c.TimerLen = timer + } +} + +func testFunc() { + return +} + +func Setup(opts ...option) error { + cfg = new(config) + + for _, o := range opts { + o(cfg) + } + + if cfg.LogFile == "" { + return fmt.Errorf("pendulum activity log file option not set") + } + + if _, err := os.Stat(cfg.LogFile); err != nil && os.IsNotExist(err) { + f, err := os.Create(cfg.LogFile) + if err != nil { + return fmt.Errorf("pendulum activity log does not exist and could not be created") + } + if _, err := f.Write([]byte("active,branch,cwd,file,filetype,project,time\n")); err != nil { + return fmt.Errorf("failed to write pendulum header") + } + } + + return nil +} + +func Config() *config { + return cfg +} diff --git a/internal/handlers/__snapshots__/formatter_direct.snap b/internal/handlers/__snapshots__/formatter_direct.snap new file mode 100644 index 0000000..4acc982 --- /dev/null +++ b/internal/handlers/__snapshots__/formatter_direct.snap @@ -0,0 +1,20 @@ +--- +title: formatter_direct +test_name: TestFormatterSnapshotDirectly +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Metrics Report +**Generated:** +**Time Range:** all +**Log File:** /test/pendulum.csv + +## Top 2 Branches +1. main : Total 30.00m, Active 25.00m (83.30%) +2. feature/auth : Total 20.00m, Active 18.00m (90.00%) + + +## Top 2 Projects +1. myproject : Total 45.00m, Active 40.00m (88.90%) +2. webapp : Total 15.00m, Active 12.00m (80.00%) + diff --git a/internal/handlers/__snapshots__/hours_formatter_direct.snap b/internal/handlers/__snapshots__/hours_formatter_direct.snap new file mode 100644 index 0000000..7cb419c --- /dev/null +++ b/internal/handlers/__snapshots__/hours_formatter_direct.snap @@ -0,0 +1,16 @@ +--- +title: hours_formatter_direct +test_name: TestHoursFormatterSnapshotDirectly +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Hours Report +**Generated:** +**Log File:** /test/pendulum.csv + +## Times Most Active + Time Overall (Active %) This Week (Active %) Entry Count +1. 10 25.00m (83.33%) 12.00m (80.00%) 3 +2. 14 15.00m (75.00%) 8.00m (80.00%) 1 +3. 9 10.00m (66.67%) 5.00m (62.50%) 2 + diff --git a/internal/handlers/__snapshots__/hours_report_12h.snap b/internal/handlers/__snapshots__/hours_report_12h.snap new file mode 100644 index 0000000..6aebb56 --- /dev/null +++ b/internal/handlers/__snapshots__/hours_report_12h.snap @@ -0,0 +1,18 @@ +--- +title: hours_report_12h +test_name: TestHoursReportSnapshot12h +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Hours Report +**Generated:** +**Log File:** + +## Times Most Active + Time Overall (Active %) This Week (Active %) Entry Count +1. 4PM 19.00m (90.48%) 0s ( 0.00%) 21 +2. 10AM 13.00m (86.67%) 0s ( 0.00%) 15 +3. 2PM 13.00m (86.67%) 0s ( 0.00%) 15 +4. 5PM 11.00m (84.62%) 0s ( 0.00%) 13 +5. 3PM 9.00m (90.00%) 0s ( 0.00%) 10 + diff --git a/internal/handlers/__snapshots__/hours_report_all.snap b/internal/handlers/__snapshots__/hours_report_all.snap new file mode 100644 index 0000000..dcaba6f --- /dev/null +++ b/internal/handlers/__snapshots__/hours_report_all.snap @@ -0,0 +1,18 @@ +--- +title: hours_report_all +test_name: TestHoursReportSnapshot +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Hours Report +**Generated:** +**Log File:** + +## Times Most Active + Time Overall (Active %) This Week (Active %) Entry Count +1. 16 19.00m (90.48%) 0s ( 0.00%) 21 +2. 10 13.00m (86.67%) 0s ( 0.00%) 15 +3. 14 13.00m (86.67%) 0s ( 0.00%) 15 +4. 17 11.00m (84.62%) 0s ( 0.00%) 13 +5. 15 9.00m (90.00%) 0s ( 0.00%) 10 + diff --git a/internal/handlers/__snapshots__/hours_report_top3.snap b/internal/handlers/__snapshots__/hours_report_top3.snap new file mode 100644 index 0000000..b616455 --- /dev/null +++ b/internal/handlers/__snapshots__/hours_report_top3.snap @@ -0,0 +1,16 @@ +--- +title: hours_report_top3 +test_name: TestHoursReportSnapshotTopN +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Hours Report +**Generated:** +**Log File:** + +## Times Most Active + Time Overall (Active %) This Week (Active %) Entry Count +1. 16 19.00m (90.48%) 0s ( 0.00%) 21 +2. 10 13.00m (86.67%) 0s ( 0.00%) 15 +3. 14 13.00m (86.67%) 0s ( 0.00%) 15 + diff --git a/internal/handlers/__snapshots__/metrics_report_all.snap b/internal/handlers/__snapshots__/metrics_report_all.snap new file mode 100644 index 0000000..5918838 --- /dev/null +++ b/internal/handlers/__snapshots__/metrics_report_all.snap @@ -0,0 +1,47 @@ +--- +title: metrics_report_all +test_name: TestMetricsReportSnapshot +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Metrics Report +**Generated:** +**Time Range:** all +**Log File:** + +## Top 4 Branches +1. main : Total 45.00m, Active 37.00m (82.22%) +2. develop : Total 26.00m, Active 23.00m (88.46%) +3. feature/auth : Total 15.00m, Active 13.00m (86.67%) +4. bugfix/login : Total 8.00m, Active 7.00m (87.50%) + + +## Top 4 Directories +1. /home/dev/myproject : Total 39.00m, Active 32.00m (82.05%) +2. /home/dev/api-server : Total 26.00m, Active 23.00m (88.46%) +3. /home/dev/webapp : Total 25.00m, Active 22.00m (88.00%) +4. /home/dev/notes : Total 4.00m, Active 3.00m (75.00%) + + +## Top 5 Files +1. auth.go : Total 18.00m, Active 16.00m (88.89%) +2. server.py : Total 12.00m, Active 11.00m (91.67%) +3. app.js : Total 10.00m, Active 9.00m (90.00%) +4. routes.py : Total 9.00m, Active 8.00m (88.89%) +5. index.html : Total 8.00m, Active 7.00m (87.50%) + + +## Top 5 File Types +1. go : Total 33.00m, Active 27.00m (81.82%) +2. python : Total 26.00m, Active 23.00m (88.46%) +3. javascript : Total 10.00m, Active 9.00m (90.00%) +4. markdown : Total 10.00m, Active 8.00m (80.00%) +5. html : Total 8.00m, Active 7.00m (87.50%) + + +## Top 4 Projects +1. myproject : Total 39.00m, Active 32.00m (82.05%) +2. api-server : Total 26.00m, Active 23.00m (88.46%) +3. webapp : Total 25.00m, Active 22.00m (88.00%) +4. notes : Total 4.00m, Active 3.00m (75.00%) + diff --git a/internal/handlers/__snapshots__/metrics_report_exclusions.snap b/internal/handlers/__snapshots__/metrics_report_exclusions.snap new file mode 100644 index 0000000..5688801 --- /dev/null +++ b/internal/handlers/__snapshots__/metrics_report_exclusions.snap @@ -0,0 +1,28 @@ +--- +title: metrics_report_exclusions +test_name: TestMetricsReportSnapshotWithExclusions +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Metrics Report +**Generated:** +**Time Range:** all +**Log File:** + +## Top 3 Files +1. auth.go : Total 18.00m, Active 16.00m (88.89%) +2. server.py : Total 12.00m, Active 11.00m (91.67%) +3. app.js : Total 10.00m, Active 9.00m (90.00%) + + +## Top 3 File Types +1. go : Total 33.00m, Active 27.00m (81.82%) +2. python : Total 26.00m, Active 23.00m (88.46%) +3. javascript : Total 10.00m, Active 9.00m (90.00%) + + +## Top 3 Projects +1. myproject : Total 39.00m, Active 32.00m (82.05%) +2. api-server : Total 26.00m, Active 23.00m (88.46%) +3. webapp : Total 25.00m, Active 22.00m (88.00%) + diff --git a/internal/handlers/__snapshots__/metrics_report_top2.snap b/internal/handlers/__snapshots__/metrics_report_top2.snap new file mode 100644 index 0000000..cfff653 --- /dev/null +++ b/internal/handlers/__snapshots__/metrics_report_top2.snap @@ -0,0 +1,35 @@ +--- +title: metrics_report_top2 +test_name: TestMetricsReportSnapshotTopN +file_name: snapshot_test.go +version: 0.1.0 +--- +# Pendulum Metrics Report +**Generated:** +**Time Range:** all +**Log File:** + +## Top 2 Branches +1. main : Total 45.00m, Active 37.00m (82.22%) +2. develop : Total 26.00m, Active 23.00m (88.46%) + + +## Top 2 Directories +1. /home/dev/myproject : Total 39.00m, Active 32.00m (82.05%) +2. /home/dev/api-server : Total 26.00m, Active 23.00m (88.46%) + + +## Top 2 Files +1. auth.go : Total 18.00m, Active 16.00m (88.89%) +2. server.py : Total 12.00m, Active 11.00m (91.67%) + + +## Top 2 File Types +1. go : Total 33.00m, Active 27.00m (81.82%) +2. python : Total 26.00m, Active 23.00m (88.46%) + + +## Top 2 Projects +1. myproject : Total 39.00m, Active 32.00m (82.05%) +2. api-server : Total 26.00m, Active 23.00m (88.46%) + diff --git a/internal/handlers/activity.go b/internal/handlers/activity.go new file mode 100644 index 0000000..cae31cd --- /dev/null +++ b/internal/handlers/activity.go @@ -0,0 +1,148 @@ +package handlers + +import ( + "context" + "log" + "os" + "sync" + "time" + + "github.com/ptdewey/pendulum-server/internal/config" + "github.com/tliron/glsp" +) + +type ActivityManager struct { + mu sync.RWMutex + lastActiveTime time.Time + activeFlag bool + timeout time.Duration + interval time.Duration + cancel context.CancelFunc + ctx *glsp.Context + currentData *activityData +} + +var ( + activityManager *ActivityManager + managerMu sync.Mutex +) + +func GetActivityManager() *ActivityManager { + managerMu.Lock() + defer managerMu.Unlock() + return activityManager +} + +func InitializeActivityManager(ctx *glsp.Context, timeout, interval time.Duration) *ActivityManager { + managerMu.Lock() + defer managerMu.Unlock() + + if activityManager != nil { + activityManager.Stop() + } + + activityManager = &ActivityManager{ + lastActiveTime: time.Now(), + activeFlag: true, + timeout: timeout, + interval: interval, + ctx: ctx, + } + + activityManager.start() + return activityManager +} + +func (am *ActivityManager) start() { + ctx, cancel := context.WithCancel(context.Background()) + am.cancel = cancel + + go func() { + ticker := time.NewTicker(am.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + am.checkActiveStatus() + } + } + }() + + log.Printf("Activity manager started with timeout: %v, interval: %v", am.timeout, am.interval) +} + +func (am *ActivityManager) Stop() { + if am.cancel != nil { + am.cancel() + } +} + +func (am *ActivityManager) UpdateActivity() { + am.mu.Lock() + defer am.mu.Unlock() + am.lastActiveTime = time.Now() +} + +func (am *ActivityManager) SetCurrentData(data *activityData) { + am.mu.Lock() + defer am.mu.Unlock() + am.currentData = data +} + +func (am *ActivityManager) checkActiveStatus() { + am.mu.Lock() + defer am.mu.Unlock() + + now := time.Now() + isActive := now.Sub(am.lastActiveTime) < am.timeout + + // Log inactive period when transitioning from active to inactive + if !isActive && am.activeFlag { + am.activeFlag = false + if am.currentData != nil { + // Log the last active time as an active entry + data := *am.currentData + data.Active = true + data.Time = am.lastActiveTime.UTC().Format("2006-01-02 15:04:05") + am.logActivityData(&data) + } + } else if isActive && !am.activeFlag { + am.activeFlag = true + } + + // Always log current state if we have data + if am.currentData != nil { + data := *am.currentData + data.Active = isActive + data.Time = now.UTC().Format("2006-01-02 15:04:05") + am.logActivityData(&data) + } +} + +func (am *ActivityManager) logActivityData(data *activityData) { + if data.File == "" { + return + } + + data.Project = getGitProject(data.Cwd) + data.Branch = getGitBranch(data.Cwd) + + if err := writeActivityToCSV(data); err != nil { + log.Printf("Failed to write activity data: %v", err) + } +} + +func writeActivityToCSV(data *activityData) error { + f, err := os.OpenFile(config.Config().LogFile, os.O_WRONLY|os.O_APPEND, 0664) + if err != nil { + return err + } + defer f.Close() + + row := data.toCSV() + _, err = f.Write([]byte(row)) + return err +} diff --git a/internal/handlers/data/aggregator.go b/internal/handlers/data/aggregator.go new file mode 100644 index 0000000..df1e3d9 --- /dev/null +++ b/internal/handlers/data/aggregator.go @@ -0,0 +1,392 @@ +package data + +import ( + "context" + "log" + "regexp" + "runtime" + "strconv" + "sync" + "time" +) + +// MetricsAggregator handles concurrent aggregation of pendulum metrics +type MetricsAggregator struct { + workerCount int + params *MetricsParams +} + +// NewMetricsAggregator creates a new metrics aggregator +func NewMetricsAggregator(params *MetricsParams) *MetricsAggregator { + workerCount := min(runtime.NumCPU(), 8) // Cap at 8 workers for memory efficiency + + return &MetricsAggregator{ + workerCount: workerCount, + params: params, + } +} + +// AggregatePendulumMetrics aggregates metrics using concurrent workers +func (a *MetricsAggregator) AggregatePendulumMetrics(ctx context.Context, data [][]string) (*ProcessingResult, error) { + startTime := time.Now() + + if len(data) == 0 { + return &ProcessingResult{ + Metrics: []PendulumMetric{}, + Processed: 0, + Duration: time.Since(startTime), + }, nil + } + + // Create exclude maps + excludeMap := make(map[int]struct{}) + for _, section := range a.params.ReportSectionExcludes { + if idx, exists := CSVColumns[section]; exists { + excludeMap[idx] = struct{}{} + } + } + + // Create work channels + jobs := make(chan aggregationJob, len(CSVColumns)) + results := make(chan PendulumMetric, len(CSVColumns)) + errors := make(chan error, len(CSVColumns)) + + // Start workers + var wg sync.WaitGroup + for i := 0; i < a.workerCount; i++ { + wg.Add(1) + go a.worker(ctx, &wg, jobs, results, errors) + } + + // Send jobs + go func() { + defer close(jobs) + for colName, colIdx := range CSVColumns { + if colName == "active" || colName == "time" { + continue + } + if _, excluded := excludeMap[colIdx]; excluded { + continue + } + + select { + case jobs <- aggregationJob{ + data: data, + colIndex: colIdx, + colName: colName, + }: + case <-ctx.Done(): + return + } + } + }() + + // Close results when all workers are done + go func() { + wg.Wait() + close(results) + close(errors) + }() + + // Collect results + metrics := make([]PendulumMetric, len(CSVColumns)) + var firstError error + + for { + select { + case result, ok := <-results: + if !ok { + // Channel closed, we're done + goto done + } + metrics[result.Index] = result + + case err, ok := <-errors: + if !ok { + // Errors channel closed, ignore + continue + } + if firstError == nil { + firstError = err + } + log.Printf("Worker error: %v", err) + + case <-ctx.Done(): + return nil, &MetricsError{ + Type: ErrProcessingTimeout, + Message: "metrics aggregation cancelled", + Cause: ctx.Err(), + } + } + } + +done: + if firstError != nil { + return nil, firstError + } + + // Filter out empty metrics + var filteredMetrics []PendulumMetric + for _, metric := range metrics { + if metric.Name != "" && len(metric.Value) > 0 { + filteredMetrics = append(filteredMetrics, metric) + } + } + + return &ProcessingResult{ + Metrics: filteredMetrics, + Processed: len(data) - 1, // Exclude header + Duration: time.Since(startTime), + }, nil +} + +type aggregationJob struct { + data [][]string + colIndex int + colName string +} + +// worker processes aggregation jobs +func (a *MetricsAggregator) worker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan aggregationJob, results chan<- PendulumMetric, errors chan<- error) { + defer wg.Done() + + for job := range jobs { + select { + case <-ctx.Done(): + return + default: + } + + metric, err := a.aggregateMetric(job.data, job.colIndex, job.colName) + if err != nil { + errors <- err + continue + } + + results <- metric + } +} + +// aggregateMetric aggregates a single metric column +func (a *MetricsAggregator) aggregateMetric(data [][]string, colIdx int, colName string) (PendulumMetric, error) { + metric := PendulumMetric{ + Name: data[0][colIdx], + Index: colIdx, + Value: make(map[string]*PendulumEntry), + } + + timecol := CSVColumns["time"] + + // Handle cwd vs directory naming inconsistency + filterColName := colName + if colName == "cwd" { + filterColName = "directory" + } + + // Compile exclusion patterns + var exclusionPatterns []*regexp.Regexp + if filters, exists := a.params.ReportExcludes[filterColName]; exists { + var err error + exclusionPatterns, err = CompileRegexPatterns(filters) + if err != nil { + return metric, err + } + } + + // Create time range filter once (pre-computes boundaries) + timeFilter, err := NewTimeRangeFilter(a.params.TimeRange, a.params.TimeZone) + if err != nil { + return metric, err + } + + // Process each row + for i := 1; i < len(data); i++ { + if len(data[i]) <= colIdx || len(data[i]) <= timecol { + continue // Skip malformed rows + } + + active, err := strconv.ParseBool(data[i][0]) + if err != nil { + log.Printf("Error parsing boolean at row %d, value: %s, error: %v", i, data[i][0], err) + continue + } + + // Check time range using pre-computed filter + inRange, err := timeFilter.InRange(data[i][timecol]) + if err != nil { + log.Printf("Error checking timestamp range: %v", err) + continue + } + if !inRange { + continue + } + + val := data[i][colIdx] + if IsExcluded(val, exclusionPatterns) { + continue + } + + // Initialize entry if doesn't exist + if metric.Value[val] == nil { + metric.Value[val] = &PendulumEntry{ + ID: val, + ActiveCount: 0, + TotalCount: 0, + ActiveTime: 0, + TotalTime: 0, + Timestamps: make([]string, 0), + ActiveTimestamps: make([]string, 0), + ActivePct: 0, + } + } + entry := metric.Value[val] + + // Update total metrics + entry.updateTotalMetrics(data[i][timecol], a.params.TimeoutLen) + + // Update active metrics if active + if active { + entry.updateActiveMetrics(data[i][timecol], a.params.TimeoutLen) + } + } + + // Calculate active percentages + a.calculateActivePercentages(metric.Value) + + return metric, nil +} + +// updateTotalMetrics updates total count and time for an entry +func (entry *PendulumEntry) updateTotalMetrics(timestampStr string, timeoutLen float64) { + entry.Timestamps = append(entry.Timestamps, timestampStr) + tt, _ := TimeDiff(entry.Timestamps, timeoutLen, false) + entry.TotalCount++ + entry.TotalTime += tt +} + +// updateActiveMetrics updates active count and time for an entry +func (entry *PendulumEntry) updateActiveMetrics(timestampStr string, timeoutLen float64) { + entry.ActiveTimestamps = append(entry.ActiveTimestamps, timestampStr) + at, _ := TimeDiff(entry.ActiveTimestamps, timeoutLen, false) + entry.ActiveCount++ + entry.ActiveTime += at +} + +// calculateActivePercentages calculates the active percentage for all entries +func (a *MetricsAggregator) calculateActivePercentages(values map[string]*PendulumEntry) { + for _, v := range values { + if v.TotalTime > 0 { + v.ActivePct = float64(v.ActiveTime) / float64(v.TotalTime) + } + } +} + +// AggregatePendulumHours aggregates hourly activity data from CSV records +func (a *MetricsAggregator) AggregatePendulumHours(ctx context.Context, data [][]string) (*HoursResult, error) { + startTime := time.Now() + + if len(data) <= 1 { + return &HoursResult{ + Hours: &PendulumHours{ + ActiveTimestamps: []string{}, + Timestamps: []string{}, + ActiveTimeHours: make(map[int]time.Duration), + ActiveTimeHoursRecent: make(map[int]time.Duration), + TotalTimeHours: make(map[int]time.Duration), + TotalTimeHoursRecent: make(map[int]time.Duration), + }, + Processed: 0, + Duration: time.Since(startTime), + }, nil + } + + hours := &PendulumHours{ + ActiveTimestamps: []string{}, + Timestamps: []string{}, + ActiveTimeHours: make(map[int]time.Duration), + ActiveTimeHoursRecent: make(map[int]time.Duration), + TotalTimeHours: make(map[int]time.Duration), + TotalTimeHoursRecent: make(map[int]time.Duration), + } + + timecol := CSVColumns["time"] + + // Create time range filter for "recent" (last week) + weekFilter, err := NewTimeRangeFilter("week", a.params.TimeZone) + if err != nil { + return nil, err + } + + for i := 1; i < len(data); i++ { + select { + case <-ctx.Done(): + return nil, &MetricsError{ + Type: ErrProcessingTimeout, + Message: "hours aggregation cancelled", + Cause: ctx.Err(), + } + default: + } + + if len(data[i]) <= timecol { + continue + } + + active, err := strconv.ParseBool(data[i][0]) + if err != nil { + log.Printf("Error parsing boolean at row %d, value: %s, error: %v", i, data[i][0], err) + continue + } + + timestampStr := data[i][timecol] + a.updateTotalHours(hours, timestampStr, weekFilter) + + if active { + a.updateActiveHours(hours, timestampStr, weekFilter) + } + } + + return &HoursResult{ + Hours: hours, + Processed: len(data) - 1, + Duration: time.Since(startTime), + }, nil +} + +// updateTotalHours updates total time per hour +func (a *MetricsAggregator) updateTotalHours(hours *PendulumHours, timestampStr string, weekFilter *TimeRangeFilter) { + hours.Timestamps = append(hours.Timestamps, timestampStr) + + t, err := time.Parse("2006-01-02 15:04:05", timestampStr) + if err != nil { + log.Printf("Error parsing timestamp: %s, error: %v", timestampStr, err) + return + } + + tth, _ := TimeDiff(hours.Timestamps, a.params.TimeoutLen, true) + hours.TotalTimeHours[t.Hour()] += tth + + inRange, _ := weekFilter.InRange(timestampStr) + if inRange { + hours.TotalTimeHoursRecent[t.Hour()] += tth + } +} + +// updateActiveHours updates active time per hour +func (a *MetricsAggregator) updateActiveHours(hours *PendulumHours, timestampStr string, weekFilter *TimeRangeFilter) { + hours.ActiveTimestamps = append(hours.ActiveTimestamps, timestampStr) + + t, err := time.Parse("2006-01-02 15:04:05", timestampStr) + if err != nil { + log.Printf("Error parsing timestamp: %s, error: %v", timestampStr, err) + return + } + + ath, _ := TimeDiff(hours.ActiveTimestamps, a.params.TimeoutLen, true) + hours.ActiveTimeHours[t.Hour()] += ath + + inRange, _ := weekFilter.InRange(timestampStr) + if inRange { + hours.ActiveTimeHoursRecent[t.Hour()] += ath + } +} diff --git a/internal/handlers/data/aggregator_test.go b/internal/handlers/data/aggregator_test.go new file mode 100644 index 0000000..ca718e2 --- /dev/null +++ b/internal/handlers/data/aggregator_test.go @@ -0,0 +1,234 @@ +package data + +import ( + "context" + "fmt" + "testing" +) + +func TestNewMetricsAggregator(t *testing.T) { + params := &MetricsParams{ + LogFile: "test.csv", + TopN: 5, + TimeRange: "all", + ReportExcludes: make(map[string][]string), + ReportSectionExcludes: []string{}, + TimeZone: "UTC", + TimeoutLen: 180.0, + } + + aggregator := NewMetricsAggregator(params) + + if aggregator.params != params { + t.Error("Expected params to be set correctly") + } + + if aggregator.workerCount <= 0 { + t.Error("Expected positive worker count") + } +} + +func TestMetricsAggregator_AggregatePendulumMetrics(t *testing.T) { + params := &MetricsParams{ + TopN: 5, + TimeRange: "all", + ReportExcludes: make(map[string][]string), + ReportSectionExcludes: []string{}, + TimeZone: "UTC", + TimeoutLen: 180.0, + } + + aggregator := NewMetricsAggregator(params) + + tests := []struct { + name string + data [][]string + expectError bool + expectCount int + }{ + { + name: "empty data", + data: [][]string{}, + expectError: false, + expectCount: 0, + }, + { + name: "header only", + data: [][]string{ + {"active", "branch", "directory", "file", "filetype", "project", "time"}, + }, + expectError: false, + expectCount: 0, + }, + { + name: "normal data", + data: [][]string{ + {"active", "branch", "directory", "file", "filetype", "project", "time"}, + {"true", "main", "/home/user", "test.go", "go", "myproject", "2024-01-01 10:00:00"}, + {"false", "main", "/home/user", "test.go", "go", "myproject", "2024-01-01 10:01:00"}, + {"true", "feature", "/home/user", "main.go", "go", "myproject", "2024-01-01 10:02:00"}, + }, + expectError: false, + expectCount: 5, // branch, directory, file, filetype, project + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + result, err := aggregator.AggregatePendulumMetrics(ctx, tt.data) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(result.Metrics) != tt.expectCount { + t.Errorf("Expected %d metrics, got %d", tt.expectCount, len(result.Metrics)) + } + + if len(tt.data) > 1 { + expectedProcessed := len(tt.data) - 1 // Exclude header + if result.Processed != expectedProcessed { + t.Errorf("Expected %d processed rows, got %d", expectedProcessed, result.Processed) + } + } + }) + } +} + +func TestMetricsAggregator_WithExclusions(t *testing.T) { + params := &MetricsParams{ + TopN: 5, + TimeRange: "all", + TimeZone: "UTC", + TimeoutLen: 180.0, + ReportExcludes: map[string][]string{ + "file": {"test.*"}, + }, + ReportSectionExcludes: []string{"branch"}, + } + + aggregator := NewMetricsAggregator(params) + + data := [][]string{ + {"active", "branch", "directory", "file", "filetype", "project", "time"}, + {"true", "main", "/home/user", "test.go", "go", "myproject", "2024-01-01 10:00:00"}, + {"true", "main", "/home/user", "main.go", "go", "myproject", "2024-01-01 10:01:00"}, + } + + ctx := context.Background() + result, err := aggregator.AggregatePendulumMetrics(ctx, data) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Should exclude branch section entirely + for _, metric := range result.Metrics { + if metric.Name == "branch" { + t.Error("Expected branch metric to be excluded") + } + + // For file metric, should exclude test.go but include main.go + if metric.Name == "file" { + if _, exists := metric.Value["test.go"]; exists { + t.Error("Expected test.go to be excluded") + } + if _, exists := metric.Value["main.go"]; !exists { + t.Error("Expected main.go to be included") + } + } + } +} + +func TestMetricsAggregator_ContextCancellation(t *testing.T) { + params := &MetricsParams{ + TopN: 5, + TimeRange: "all", + ReportExcludes: make(map[string][]string), + ReportSectionExcludes: []string{}, + TimeZone: "UTC", + TimeoutLen: 180.0, + } + + aggregator := NewMetricsAggregator(params) + + data := [][]string{ + {"active", "branch", "directory", "file", "filetype", "project", "time"}, + {"true", "main", "/home/user", "test.go", "go", "myproject", "2024-01-01 10:00:00"}, + } + + // Cancel context immediately + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := aggregator.AggregatePendulumMetrics(ctx, data) + + if err == nil { + t.Error("Expected error for cancelled context") + } +} + +func BenchmarkMetricsAggregation(b *testing.B) { + params := &MetricsParams{ + TopN: 5, + TimeRange: "all", + ReportExcludes: make(map[string][]string), + ReportSectionExcludes: []string{}, + TimeZone: "UTC", + TimeoutLen: 180.0, + } + + // Create test data with different sizes + sizes := []int{100, 1000, 10000} + + for _, size := range sizes { + b.Run(fmt.Sprintf("size-%d", size), func(b *testing.B) { + data := generateTestData(size) + aggregator := NewMetricsAggregator(params) + ctx := context.Background() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := aggregator.AggregatePendulumMetrics(ctx, data) + if err != nil { + b.Fatalf("Aggregation failed: %v", err) + } + } + }) + } +} + +// generateTestData creates test CSV data for benchmarking +func generateTestData(size int) [][]string { + data := [][]string{ + {"active", "branch", "directory", "file", "filetype", "project", "time"}, + } + + for i := 0; i < size; i++ { + active := "true" + if i%2 == 0 { + active = "false" + } + + row := []string{ + active, + "main", + "/home/user", + "test.go", + "go", + "myproject", + "2024-01-01 10:00:00", + } + data = append(data, row) + } + + return data +} diff --git a/internal/handlers/data/csv.go b/internal/handlers/data/csv.go new file mode 100644 index 0000000..9dbe001 --- /dev/null +++ b/internal/handlers/data/csv.go @@ -0,0 +1,135 @@ +package data + +import ( + "bufio" + "context" + "encoding/csv" + "os" +) + +// CSVReader provides enhanced CSV reading capabilities +type CSVReader struct { + filepath string +} + +// NewCSVReader creates a new CSV reader for the given file +func NewCSVReader(filepath string) *CSVReader { + return &CSVReader{filepath: filepath} +} + +// ReadAll reads the entire CSV file into memory +func (r *CSVReader) ReadAll() ([][]string, error) { + f, err := os.Open(r.filepath) + if err != nil { + return nil, &MetricsError{ + Type: ErrFileNotFound, + Message: "failed to open pendulum log file", + Cause: err, + } + } + defer f.Close() + + csvReader := csv.NewReader(f) + data, err := csvReader.ReadAll() + if err != nil { + return nil, &MetricsError{ + Type: ErrParsingFailed, + Message: "failed to parse CSV data", + Cause: err, + } + } + + return data, nil +} + +// StreamRows streams CSV rows one by one with context cancellation support +func (r *CSVReader) StreamRows(ctx context.Context, processor func([]string) error) error { + f, err := os.Open(r.filepath) + if err != nil { + return &MetricsError{ + Type: ErrFileNotFound, + Message: "failed to open pendulum log file", + Cause: err, + } + } + defer f.Close() + + csvReader := csv.NewReader(bufio.NewReader(f)) + + // Read header + header, err := csvReader.Read() + if err != nil { + return &MetricsError{ + Type: ErrParsingFailed, + Message: "failed to read CSV header", + Cause: err, + } + } + + // Process header + if err := processor(header); err != nil { + return err + } + + rowNum := 1 + for { + select { + case <-ctx.Done(): + return &MetricsError{ + Type: ErrProcessingTimeout, + Message: "CSV processing cancelled", + Cause: ctx.Err(), + } + default: + } + + record, err := csvReader.Read() + if err != nil { + if err.Error() == "EOF" { + break + } + return &MetricsError{ + Type: ErrParsingFailed, + Message: "failed to parse CSV row", + Cause: err, + } + } + + if err := processor(record); err != nil { + return &MetricsError{ + Type: ErrParsingFailed, + Message: "failed to process CSV row", + Cause: err, + } + } + + rowNum++ + } + + return nil +} + +// BatchStream processes CSV rows in batches for better performance +func (r *CSVReader) BatchStream(ctx context.Context, batchSize int, processor func([][]string) error) error { + var batch [][]string + + err := r.StreamRows(ctx, func(row []string) error { + batch = append(batch, row) + + if len(batch) >= batchSize { + if err := processor(batch); err != nil { + return err + } + batch = batch[:0] // Reset batch + } + + return nil + }) + + // Process remaining rows in batch + if err == nil && len(batch) > 0 { + err = processor(batch) + } + + return err +} diff --git a/internal/handlers/data/csv_test.go b/internal/handlers/data/csv_test.go new file mode 100644 index 0000000..75b36a5 --- /dev/null +++ b/internal/handlers/data/csv_test.go @@ -0,0 +1,150 @@ +package data + +import ( + "context" + "os" + "reflect" + "testing" +) + +func TestNewCSVReader(t *testing.T) { + reader := NewCSVReader("test.csv") + if reader.filepath != "test.csv" { + t.Errorf("Expected filepath 'test.csv', got '%s'", reader.filepath) + } +} + +func TestCSVReader_ReadAll(t *testing.T) { + // Create a temporary CSV file for testing + tmpFile, err := os.CreateTemp("", "test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write test data + testData := `active,branch,cwd,file,filetype,project,time +true,main,/home/user,test.go,go,myproject,2024-01-01 10:00:00 +false,main,/home/user,test.go,go,myproject,2024-01-01 10:01:00 +true,feature,/home/user,main.go,go,myproject,2024-01-01 10:02:00` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + // Test ReadAll + reader := NewCSVReader(tmpFile.Name()) + data, err := reader.ReadAll() + if err != nil { + t.Fatalf("ReadAll failed: %v", err) + } + + expectedRows := 4 // header + 3 data rows + if len(data) != expectedRows { + t.Errorf("Expected %d rows, got %d", expectedRows, len(data)) + } + + // Check header + expectedHeader := []string{"active", "branch", "cwd", "file", "filetype", "project", "time"} + if !reflect.DeepEqual(data[0], expectedHeader) { + t.Errorf("Expected header %v, got %v", expectedHeader, data[0]) + } + + // Check first data row + expectedFirstRow := []string{"true", "main", "/home/user", "test.go", "go", "myproject", "2024-01-01 10:00:00"} + if !reflect.DeepEqual(data[1], expectedFirstRow) { + t.Errorf("Expected first row %v, got %v", expectedFirstRow, data[1]) + } +} + +func TestCSVReader_StreamRows(t *testing.T) { + // Create a temporary CSV file + tmpFile, err := os.CreateTemp("", "test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + testData := `active,branch,cwd +true,main,/home +false,dev,/tmp` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + // Test streaming + reader := NewCSVReader(tmpFile.Name()) + var rows [][]string + + ctx := context.Background() + err = reader.StreamRows(ctx, func(row []string) error { + rows = append(rows, row) + return nil + }) + + if err != nil { + t.Fatalf("StreamRows failed: %v", err) + } + + if len(rows) != 3 { // header + 2 data rows + t.Errorf("Expected 3 rows, got %d", len(rows)) + } +} + +func TestCSVReader_FileNotFound(t *testing.T) { + reader := NewCSVReader("nonexistent.csv") + _, err := reader.ReadAll() + + if err == nil { + t.Error("Expected error for nonexistent file") + } + + if metricsErr, ok := err.(*MetricsError); ok { + if metricsErr.Type != ErrFileNotFound { + t.Errorf("Expected ErrFileNotFound, got %v", metricsErr.Type) + } + } else { + t.Error("Expected MetricsError type") + } +} + +func TestCSVReader_ContextCancellation(t *testing.T) { + // Create a temporary CSV file + tmpFile, err := os.CreateTemp("", "test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write some test data + testData := `active,branch +true,main +false,dev` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + // Test with cancelled context + reader := NewCSVReader(tmpFile.Name()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + err = reader.StreamRows(ctx, func(row []string) error { + return nil + }) + + if err == nil { + t.Error("Expected error for cancelled context") + } + + if metricsErr, ok := err.(*MetricsError); ok { + if metricsErr.Type != ErrProcessingTimeout { + t.Errorf("Expected ErrProcessingTimeout, got %v", metricsErr.Type) + } + } +} diff --git a/internal/handlers/data/types.go b/internal/handlers/data/types.go new file mode 100644 index 0000000..b60d6e7 --- /dev/null +++ b/internal/handlers/data/types.go @@ -0,0 +1,96 @@ +package data + +import ( + "time" +) + +// PendulumMetric represents aggregated metrics for a specific column/field +type PendulumMetric struct { + Name string + Index int + Value map[string]*PendulumEntry +} + +// PendulumEntry represents aggregated data for a specific value within a metric +type PendulumEntry struct { + ID string + ActiveCount uint + TotalCount uint + ActiveTime time.Duration + TotalTime time.Duration + ActiveTimestamps []string + Timestamps []string + ActivePct float64 +} + +// MetricsParams holds parameters for metrics generation +type MetricsParams struct { + LogFile string + TopN int + TimeRange string + ReportExcludes map[string][]string + ReportSectionExcludes []string + TimeFormat string + TimeZone string + TimeoutLen float64 +} + +// CSVColumns maps column names to their indices in the CSV +var CSVColumns = map[string]int{ + "active": 0, + "branch": 1, + "directory": 2, + "file": 3, + "filetype": 4, + "project": 5, + "time": 6, +} + +// MetricsError represents errors that can occur during metrics processing +type MetricsError struct { + Type ErrorType + Message string + Cause error +} + +func (e *MetricsError) Error() string { + if e.Cause != nil { + return e.Message + ": " + e.Cause.Error() + } + return e.Message +} + +// ErrorType defines the category of metrics processing errors +type ErrorType int + +const ( + ErrInvalidData ErrorType = iota + ErrFileNotFound + ErrParsingFailed + ErrProcessingTimeout + ErrInvalidParameters +) + +// ProcessingResult holds the results of metrics processing +type ProcessingResult struct { + Metrics []PendulumMetric + Processed int + Duration time.Duration +} + +// PendulumHours holds aggregated hourly activity data +type PendulumHours struct { + ActiveTimestamps []string + Timestamps []string + ActiveTimeHours map[int]time.Duration + ActiveTimeHoursRecent map[int]time.Duration + TotalTimeHours map[int]time.Duration + TotalTimeHoursRecent map[int]time.Duration +} + +// HoursResult holds the results of hours processing +type HoursResult struct { + Hours *PendulumHours + Processed int + Duration time.Duration +} diff --git a/internal/handlers/data/utils.go b/internal/handlers/data/utils.go new file mode 100644 index 0000000..da1ec53 --- /dev/null +++ b/internal/handlers/data/utils.go @@ -0,0 +1,197 @@ +package data + +import ( + "fmt" + "regexp" + "time" +) + +// timeDiff calculates the time difference between the last two timestamps +func TimeDiff(timestamps []string, timeoutLen float64, clamp bool) (time.Duration, error) { + n := len(timestamps) + if n < 2 { + return time.Duration(0), nil + } + + curr, prev := timestamps[n-1], timestamps[n-2] + var d time.Duration + var err error + + if !clamp { + d, err = calcDuration(curr, prev) + } else { + d, err = calcDurationWithinHour(curr, prev) + } + + if err != nil { + return time.Duration(0), &MetricsError{ + Type: ErrParsingFailed, + Message: "failed to calculate time difference", + Cause: err, + } + } + + // If difference exceeds timeout, editor was closed between sessions + if d.Seconds() > timeoutLen { + return time.Duration(0), nil + } + + return d, nil +} + +// calcDuration calculates the duration between two string timestamps (curr - prev) +func calcDuration(curr string, prev string) (time.Duration, error) { + layout := "2006-01-02 15:04:05" + + currT, err := time.Parse(layout, curr) + if err != nil { + return time.Duration(0), fmt.Errorf("failed to parse current timestamp %s: %w", curr, err) + } + + prevT, err := time.Parse(layout, prev) + if err != nil { + return time.Duration(0), fmt.Errorf("failed to parse previous timestamp %s: %w", prev, err) + } + + return currT.Sub(prevT), nil +} + +// calcDurationWithinHour calculates duration clamped to the hour boundary +func calcDurationWithinHour(curr string, prev string) (time.Duration, error) { + layout := "2006-01-02 15:04:05" + + currT, err := time.Parse(layout, curr) + if err != nil { + return 0, fmt.Errorf("failed to parse current timestamp %s: %w", curr, err) + } + + prevT, err := time.Parse(layout, prev) + if err != nil { + return 0, fmt.Errorf("failed to parse previous timestamp %s: %w", prev, err) + } + + // If prev_t is within the same hour as curr_t, return the direct difference + if prevT.Hour() == currT.Hour() && prevT.Day() == currT.Day() { + return currT.Sub(prevT), nil + } + + // Otherwise, clamp prev_t to the start of curr_t's hour + clampedPrevT := time.Date(currT.Year(), currT.Month(), currT.Day(), currT.Hour(), + 0, 0, 0, currT.Location()) + + return currT.Sub(clampedPrevT), nil +} + +// CompileRegexPatterns compiles regex patterns for exclusion filtering +func CompileRegexPatterns(filters []string) ([]*regexp.Regexp, error) { + if len(filters) == 0 { + return nil, nil + } + + patterns := make([]*regexp.Regexp, 0, len(filters)) + for _, expr := range filters { + r, err := regexp.Compile(expr) + if err != nil { + return nil, &MetricsError{ + Type: ErrInvalidParameters, + Message: fmt.Sprintf("failed to compile regex pattern: %s", expr), + Cause: err, + } + } + patterns = append(patterns, r) + } + + return patterns, nil +} + +// IsExcluded checks if a value matches any exclusion pattern +func IsExcluded(val string, patterns []*regexp.Regexp) bool { + for _, r := range patterns { + if r.MatchString(val) { + return true + } + } + return false +} + +// TimeRangeFilter provides efficient time range filtering with pre-computed boundaries +type TimeRangeFilter struct { + startOfRange time.Time + endOfRange time.Time + loc *time.Location + layout string + isAll bool +} + +// NewTimeRangeFilter creates a filter with pre-computed time boundaries +func NewTimeRangeFilter(rangeType, timeZone string) (*TimeRangeFilter, error) { + if rangeType == "all" { + return &TimeRangeFilter{isAll: true}, nil + } + + loc, err := time.LoadLocation(timeZone) + if err != nil { + loc = time.UTC + } + + now := time.Now().In(loc) + var startOfRange, endOfRange time.Time + + switch rangeType { + case "today", "day": + startOfRange = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + endOfRange = startOfRange.Add(24 * time.Hour) + case "year": + startOfRange = time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc) + endOfRange = startOfRange.AddDate(1, 0, 0) + case "month": + startOfRange = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) + endOfRange = startOfRange.AddDate(0, 1, 0) + case "week": + startOfRange = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -6) + endOfRange = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).Add(24 * time.Hour) + case "hour": + startOfRange = now.Truncate(time.Hour) + endOfRange = startOfRange.Add(time.Hour) + default: + return nil, &MetricsError{ + Type: ErrInvalidParameters, + Message: fmt.Sprintf("unsupported time range: %s", rangeType), + } + } + + return &TimeRangeFilter{ + startOfRange: startOfRange, + endOfRange: endOfRange, + loc: loc, + layout: "2006-01-02 15:04:05", + isAll: false, + }, nil +} + +// InRange checks if a timestamp string falls within the pre-computed range +// Note: timestamps in the CSV are stored in UTC, so we parse them as UTC +// and compare against the range boundaries (which are also in UTC internally) +func (f *TimeRangeFilter) InRange(timestampStr string) (bool, error) { + if f.isAll { + return true, nil + } + + // Timestamps in CSV are always in UTC + timestamp, err := time.Parse(f.layout, timestampStr) + if err != nil { + return false, err + } + + return !timestamp.Before(f.startOfRange) && timestamp.Before(f.endOfRange), nil +} + +// IsTimestampInRange checks if a timestamp falls within the specified time range +// Deprecated: Use TimeRangeFilter for better performance when checking multiple timestamps +func IsTimestampInRange(timestampStr, rangeType, timeZone string) (bool, error) { + filter, err := NewTimeRangeFilter(rangeType, timeZone) + if err != nil { + return false, err + } + return filter.InRange(timestampStr) +} diff --git a/internal/handlers/data/utils_test.go b/internal/handlers/data/utils_test.go new file mode 100644 index 0000000..5478dce --- /dev/null +++ b/internal/handlers/data/utils_test.go @@ -0,0 +1,454 @@ +package data + +import ( + "testing" + "time" +) + +func TestTimeDiff(t *testing.T) { + tests := []struct { + name string + timestamps []string + timeoutLen float64 + clamp bool + expected time.Duration + expectError bool + }{ + { + name: "empty timestamps", + timestamps: []string{}, + timeoutLen: 180.0, + clamp: false, + expected: 0, + }, + { + name: "single timestamp", + timestamps: []string{"2024-01-01 10:00:00"}, + timeoutLen: 180.0, + clamp: false, + expected: 0, + }, + { + name: "normal difference", + timestamps: []string{"2024-01-01 10:00:00", "2024-01-01 10:01:00"}, + timeoutLen: 180.0, + clamp: false, + expected: time.Minute, + }, + { + name: "exceeds timeout", + timestamps: []string{"2024-01-01 10:00:00", "2024-01-01 12:00:00"}, + timeoutLen: 180.0, + clamp: false, + expected: 0, // Should return 0 because it exceeds timeout + }, + { + name: "invalid timestamp format", + timestamps: []string{"invalid", "2024-01-01 10:01:00"}, + timeoutLen: 180.0, + clamp: false, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := TimeDiff(tt.timestamps, tt.timeoutLen, tt.clamp) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestCompileRegexPatterns(t *testing.T) { + tests := []struct { + name string + filters []string + expectError bool + expectCount int + }{ + { + name: "empty filters", + filters: []string{}, + expectError: false, + expectCount: 0, + }, + { + name: "valid patterns", + filters: []string{"test.*", "^main$"}, + expectError: false, + expectCount: 2, + }, + { + name: "invalid pattern", + filters: []string{"[invalid"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + patterns, err := CompileRegexPatterns(tt.filters) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(patterns) != tt.expectCount { + t.Errorf("Expected %d patterns, got %d", tt.expectCount, len(patterns)) + } + }) + } +} + +func TestIsExcluded(t *testing.T) { + patterns, err := CompileRegexPatterns([]string{"test.*", "^main$"}) + if err != nil { + t.Fatalf("Failed to compile patterns: %v", err) + } + + tests := []struct { + name string + value string + expected bool + }{ + {"matches test pattern", "test123", true}, + {"matches main pattern", "main", true}, + {"no match", "other", false}, + {"partial main match", "mainline", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsExcluded(tt.value, patterns) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestIsTimestampInRange(t *testing.T) { + tests := []struct { + name string + timestamp string + rangeType string + timeZone string + expectError bool + // Note: We can't easily test the actual range logic without mocking time.Now() + // so we focus on error conditions and "all" range + }{ + { + name: "all range always true", + timestamp: "2024-01-01 10:00:00", + rangeType: "all", + timeZone: "UTC", + }, + { + name: "invalid timestamp", + timestamp: "invalid", + rangeType: "day", + timeZone: "UTC", + expectError: true, + }, + { + name: "invalid range type", + timestamp: "2024-01-01 10:00:00", + rangeType: "invalid", + timeZone: "UTC", + expectError: true, + }, + { + name: "valid day range", + timestamp: "2024-01-01 10:00:00", + rangeType: "day", + timeZone: "UTC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := IsTimestampInRange(tt.timestamp, tt.rangeType, tt.timeZone) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if tt.rangeType == "all" && !result { + t.Error("Expected 'all' range to return true") + } + }) + } +} + +func TestIsTimestampInRange_Boundaries(t *testing.T) { + // Test boundary conditions using fixed times relative to a known "now" + // We'll test the logic by creating timestamps that should definitely be in or out of range + + now := time.Now() + loc := time.UTC + layout := "2006-01-02 15:04:05" + + // Generate test timestamps + startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + midToday := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, loc) + yesterday := startOfToday.AddDate(0, 0, -1) + tomorrow := startOfToday.AddDate(0, 0, 1) + + tests := []struct { + name string + timestamp time.Time + rangeType string + expected bool + }{ + // Day tests + {"start of today is in day range", startOfToday, "day", true}, + {"mid today is in day range", midToday, "day", true}, + {"yesterday is not in day range", yesterday, "day", false}, + {"tomorrow is not in day range", tomorrow, "day", false}, + + // Week tests (last 7 days including today) + {"today is in week range", midToday, "week", true}, + {"6 days ago is in week range", startOfToday.AddDate(0, 0, -6).Add(time.Hour), "week", true}, + {"8 days ago is not in week range", startOfToday.AddDate(0, 0, -8), "week", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + timestampStr := tt.timestamp.In(loc).Format(layout) + result, err := IsTimestampInRange(timestampStr, tt.rangeType, "UTC") + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result != tt.expected { + t.Errorf("For timestamp %s with range %s: expected %v, got %v", + timestampStr, tt.rangeType, tt.expected, result) + } + }) + } +} + +func BenchmarkTimeDiff(b *testing.B) { + timestamps := []string{"2024-01-01 10:00:00", "2024-01-01 10:01:00"} + + b.ResetTimer() + for b.Loop() { + _, _ = TimeDiff(timestamps, 180.0, false) + } +} + +func BenchmarkCompileRegexPatterns(b *testing.B) { + patterns := []string{"test.*", "^main$", ".*\\.go$"} + + b.ResetTimer() + for b.Loop() { + _, _ = CompileRegexPatterns(patterns) + } +} + +func TestTimeRangeFilter(t *testing.T) { + t.Run("all range", func(t *testing.T) { + filter, err := NewTimeRangeFilter("all", "UTC") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Any timestamp should be in range for "all" + inRange, err := filter.InRange("2020-01-01 00:00:00") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !inRange { + t.Error("Expected 'all' filter to include any timestamp") + } + }) + + t.Run("invalid range type", func(t *testing.T) { + _, err := NewTimeRangeFilter("invalid", "UTC") + if err == nil { + t.Error("Expected error for invalid range type") + } + }) + + t.Run("invalid timezone falls back to UTC", func(t *testing.T) { + filter, err := NewTimeRangeFilter("day", "Invalid/Timezone") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + // Should not error, just use UTC + if filter.loc.String() != "UTC" { + t.Errorf("Expected UTC fallback, got %s", filter.loc.String()) + } + }) +} + +func TestTimeRangeFilter_Boundaries(t *testing.T) { + loc := time.UTC + layout := "2006-01-02 15:04:05" + now := time.Now().In(loc) + + // Generate boundary timestamps + startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + endOfToday := startOfToday.Add(24*time.Hour - time.Nanosecond) + midToday := startOfToday.Add(12 * time.Hour) + yesterday := startOfToday.AddDate(0, 0, -1) + tomorrow := startOfToday.AddDate(0, 0, 1) + + startOfWeek := startOfToday.AddDate(0, 0, -6) + beforeWeek := startOfWeek.Add(-time.Hour) + + startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc) + lastMonth := startOfMonth.AddDate(0, 0, -1) + + startOfYear := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc) + lastYear := startOfYear.AddDate(0, 0, -1) + + tests := []struct { + name string + rangeType string + timestamp time.Time + expected bool + }{ + // Day range tests + {"day: start of today (inclusive)", "day", startOfToday, true}, + {"day: mid today", "day", midToday, true}, + {"day: end of today", "day", endOfToday, true}, + {"day: yesterday excluded", "day", yesterday, false}, + {"day: tomorrow excluded", "day", tomorrow, false}, + + // Week range tests (last 7 days) + {"week: today included", "week", midToday, true}, + {"week: start of week (inclusive)", "week", startOfWeek, true}, + {"week: 6 days ago mid-day", "week", startOfWeek.Add(12 * time.Hour), true}, + {"week: before week excluded", "week", beforeWeek, false}, + + // Month range tests + {"month: today included", "month", midToday, true}, + {"month: start of month (inclusive)", "month", startOfMonth, true}, + {"month: last month excluded", "month", lastMonth, false}, + + // Year range tests + {"year: today included", "year", midToday, true}, + {"year: start of year (inclusive)", "year", startOfYear, true}, + {"year: last year excluded", "year", lastYear, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter, err := NewTimeRangeFilter(tt.rangeType, "UTC") + if err != nil { + t.Fatalf("Failed to create filter: %v", err) + } + + timestampStr := tt.timestamp.Format(layout) + result, err := filter.InRange(timestampStr) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result != tt.expected { + t.Errorf("For %s with range %s: expected %v, got %v (timestamp: %s, start: %s, end: %s)", + tt.name, tt.rangeType, tt.expected, result, + timestampStr, + filter.startOfRange.Format(layout), + filter.endOfRange.Format(layout)) + } + }) + } +} + +func TestTimeRangeFilter_Timezones(t *testing.T) { + // Test that timezone handling works correctly + layout := "2006-01-02 15:04:05" + + // Create a filter for Eastern time + filter, err := NewTimeRangeFilter("day", "America/New_York") + if err != nil { + t.Fatalf("Failed to create filter: %v", err) + } + + // Get current time in Eastern + eastern, _ := time.LoadLocation("America/New_York") + now := time.Now().In(eastern) + midToday := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, eastern) + + // This timestamp should be in range + timestampStr := midToday.Format(layout) + inRange, err := filter.InRange(timestampStr) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !inRange { + t.Errorf("Expected mid-day Eastern timestamp to be in range") + } +} + +func TestTimeRangeFilter_InvalidTimestamp(t *testing.T) { + filter, err := NewTimeRangeFilter("day", "UTC") + if err != nil { + t.Fatalf("Failed to create filter: %v", err) + } + + _, err = filter.InRange("not-a-timestamp") + if err == nil { + t.Error("Expected error for invalid timestamp") + } +} + +func BenchmarkTimeRangeFilter_InRange(b *testing.B) { + filter, _ := NewTimeRangeFilter("week", "UTC") + timestamp := time.Now().Format("2006-01-02 15:04:05") + + b.ResetTimer() + for b.Loop() { + _, _ = filter.InRange(timestamp) + } +} + +func BenchmarkTimeRangeFilter_VsIsTimestampInRange(b *testing.B) { + timestamp := time.Now().Format("2006-01-02 15:04:05") + + b.Run("TimeRangeFilter (reused)", func(b *testing.B) { + filter, _ := NewTimeRangeFilter("week", "America/New_York") + b.ResetTimer() + for b.Loop() { + _, _ = filter.InRange(timestamp) + } + }) + + b.Run("IsTimestampInRange (creates filter each time)", func(b *testing.B) { + b.ResetTimer() + for b.Loop() { + _, _ = IsTimestampInRange(timestamp, "week", "America/New_York") + } + }) +} diff --git a/internal/handlers/logger.go b/internal/handlers/logger.go new file mode 100644 index 0000000..d0f4cd8 --- /dev/null +++ b/internal/handlers/logger.go @@ -0,0 +1,162 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/ptdewey/pendulum-server/internal/config" + "github.com/tliron/glsp" +) + +type activityData struct { + Active bool `json:"active"` + Branch string `json:"branch"` + Cwd string `json:"cwd"` + File string `json:"file"` + Filetype string `json:"filetype"` + Project string `json:"project"` + Time string `json:"time"` +} + +func LogActivity(ctx *glsp.Context, args []any) (bool, error) { + if len(args) == 0 { + return false, fmt.Errorf("no arguments provided") + } + + // Convert to JSON and back to populate struct fields automatically + jsonBytes, err := json.Marshal(args[0]) + if err != nil { + return false, fmt.Errorf("invalid args: %w", err) + } + + var ad activityData + if err := json.Unmarshal(jsonBytes, &ad); err != nil { + return false, fmt.Errorf("failed to parse activity data: %w", err) + } + + am := GetActivityManager() + if am != nil { + // Update activity manager with current data + am.SetCurrentData(&ad) + am.UpdateActivity() + + // Immediately log this activity data + ad.Project = getGitProject(ad.Cwd) + ad.Branch = getGitBranch(ad.Cwd) + if err := writeActivityToCSV(&ad); err != nil { + log.Printf("Failed to write activity data: %v", err) + } + } else { + // Fallback to direct logging if manager not available + ad.Project = getGitProject(ad.Cwd) + ad.Branch = getGitBranch(ad.Cwd) + + row := ad.toCSV() + + f, err := os.OpenFile(config.Config().LogFile, os.O_WRONLY|os.O_APPEND, 0664) + if err != nil { + return false, err + } + defer f.Close() + + if _, err := f.Write([]byte(row)); err != nil { + return false, err + } + } + + return true, nil +} + +func getGitBranch(cwd string) string { + cmd := exec.Command("git", "branch", "--show-current") + cmd.Dir = cwd + + output, err := cmd.Output() + if err != nil { + return "unknown_branch" + } + + branch := strings.TrimSpace(string(output)) + if branch == "" || strings.HasPrefix(branch, "fatal:") { + return "unknown_branch" + } + + return branch +} + +func getGitProject(cwd string) string { + cmd := exec.Command("git", "config", "--local", "remote.origin.url") + cmd.Dir = cwd + + output, err := cmd.Output() + if err != nil { + return "unknown_project" + } + + url := strings.TrimSpace(string(output)) + + re := regexp.MustCompile(`.*/([^.]+)\.git$`) + matches := re.FindStringSubmatch(url) + if len(matches) >= 2 { + return matches[1] + } + + return "unknown_project" +} + +func (ad *activityData) toCSV() string { + return fmt.Sprintf("%t,%s,%s,%s,%s,%s,%s\n", + ad.Active, + ad.Branch, + ad.Cwd, + ad.File, + ad.Filetype, + ad.Project, + ad.Time, + ) +} + +func ActivityPing(ctx *glsp.Context, args []any) (bool, error) { + am := GetActivityManager() + if am == nil { + return false, fmt.Errorf("activity manager not initialized") + } + + am.UpdateActivity() + return true, nil +} + +func StartSession(ctx *glsp.Context, args []any) (bool, error) { + log.Printf("executing command 'pendulum.startSession' with args %v", args) + + // Start with CLI config defaults + cfg := config.Config() + timeoutLen := cfg.TimeoutLen + timerLen := cfg.TimerLen + + // Stop existing manager if any + if am := GetActivityManager(); am != nil { + am.Stop() + } + + InitializeActivityManager(ctx, timeoutLen, timerLen) + log.Printf("Activity session started with timeout: %v, timer: %v", timeoutLen, timerLen) + return true, nil +} + +func EndSession(ctx *glsp.Context, args []any) (bool, error) { + log.Printf("executing command 'pendulum.endSession'") + + am := GetActivityManager() + if am != nil { + am.Stop() + } + + log.Println("Activity session ended") + return true, nil +} diff --git a/internal/handlers/metrics.go b/internal/handlers/metrics.go new file mode 100644 index 0000000..63f71be --- /dev/null +++ b/internal/handlers/metrics.go @@ -0,0 +1,180 @@ +package handlers + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/ptdewey/pendulum-server/internal/config" + "github.com/ptdewey/pendulum-server/internal/handlers/data" + "github.com/ptdewey/pendulum-server/internal/handlers/params" + "github.com/ptdewey/pendulum-server/internal/handlers/prettify" + "github.com/tliron/glsp" +) + +// GenerateMetricsReport generates a formatted metrics report via LSP +func GenerateMetricsReport(ctx *glsp.Context, args []any) (string, error) { + log.Printf("executing command 'pendulum.generateMetricsReport' with args %v", args) + + // Parse and validate parameters + metricsParams, err := params.ParseMetricsParams(args) + if err != nil { + log.Printf("Error parsing metrics parameters: %v", err) + return "", err + } + + // Log parsed exclusion settings for debugging + if len(metricsParams.ReportExcludes) > 0 { + log.Printf("ReportExcludes: %v", metricsParams.ReportExcludes) + } + if len(metricsParams.ReportSectionExcludes) > 0 { + log.Printf("ReportSectionExcludes: %v", metricsParams.ReportSectionExcludes) + } + + // Use config log file if not provided in params + if metricsParams.LogFile == "" { + cfg := config.Config() + if cfg != nil && cfg.LogFile != "" { + metricsParams.LogFile = cfg.LogFile + } else { + return "", &data.MetricsError{ + Type: data.ErrFileNotFound, + Message: "no log file specified and no default configured", + } + } + } + + // Create processing context with timeout + processingCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Read CSV data + csvReader := data.NewCSVReader(metricsParams.LogFile) + csvData, err := csvReader.ReadAll() + if err != nil { + log.Printf("Error reading CSV file: %v", err) + return "", err + } + + if len(csvData) <= 1 { + return "# No data available\n\nThe log file contains no activity data.", nil + } + + // Create aggregator and process metrics + aggregator := data.NewMetricsAggregator(metricsParams) + result, err := aggregator.AggregatePendulumMetrics(processingCtx, csvData) + if err != nil { + log.Printf("Error aggregating metrics: %v", err) + return "", err + } + + // Format results + formatter := prettify.NewMetricsFormatter(metricsParams) + formattedLines := formatter.FormatMetrics(result.Metrics) + + // Add processing metadata + metadata := []string{ + "", + "---", + "", + "**Processing Summary:**", + "- Rows processed: " + formatNumber(result.Processed), + "- Processing time: " + result.Duration.String(), + } + + formattedLines = append(formattedLines, metadata...) + + // Join all lines into a single string + output := strings.Join(formattedLines, "\n") + + log.Printf("Generated metrics report: %d lines, %d metrics, processed in %v", + len(formattedLines), len(result.Metrics), result.Duration) + + return output, nil +} + +// GenerateHourlyReport generates an hourly activity report via LSP +func GenerateHourlyReport(ctx *glsp.Context, args []any) (string, error) { + log.Printf("executing command 'pendulum.generateHourlyReport' with args %v", args) + + // Parse and validate parameters (reuse metrics params) + metricsParams, err := params.ParseMetricsParams(args) + if err != nil { + log.Printf("Error parsing metrics parameters: %v", err) + return "", err + } + + // Use config log file if not provided in params + if metricsParams.LogFile == "" { + cfg := config.Config() + if cfg != nil && cfg.LogFile != "" { + metricsParams.LogFile = cfg.LogFile + } else { + return "", &data.MetricsError{ + Type: data.ErrFileNotFound, + Message: "no log file specified and no default configured", + } + } + } + + // Create processing context with timeout + processingCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Read CSV data + csvReader := data.NewCSVReader(metricsParams.LogFile) + csvData, err := csvReader.ReadAll() + if err != nil { + log.Printf("Error reading CSV file: %v", err) + return "", err + } + + if len(csvData) <= 1 { + return "# No data available\n\nThe log file contains no activity data.", nil + } + + // Create aggregator and process hours + aggregator := data.NewMetricsAggregator(metricsParams) + result, err := aggregator.AggregatePendulumHours(processingCtx, csvData) + if err != nil { + log.Printf("Error aggregating hours: %v", err) + return "", err + } + + // Format results + formatter := prettify.NewMetricsFormatter(metricsParams) + formattedLines := formatter.FormatHours(result.Hours, metricsParams.TopN) + + // Add processing metadata + metadata := []string{ + "", + "---", + "", + "**Processing Summary:**", + "- Rows processed: " + formatNumber(result.Processed), + "- Processing time: " + result.Duration.String(), + } + + formattedLines = append(formattedLines, metadata...) + + // Join all lines into a single string + output := strings.Join(formattedLines, "\n") + + log.Printf("Generated hourly report: %d lines, processed in %v", + len(formattedLines), result.Duration) + + return output, nil +} + +// formatNumber formats a number for display with commas +func formatNumber(n int) string { + if n < 1000 { + return fmt.Sprintf("%d", n) + } + if n < 1000000 { + return fmt.Sprintf("%d,%03d", n/1000, n%1000) + } + return fmt.Sprintf("%.1fM", float64(n)/1000000) +} diff --git a/internal/handlers/metrics_test.go b/internal/handlers/metrics_test.go new file mode 100644 index 0000000..b3f8b25 --- /dev/null +++ b/internal/handlers/metrics_test.go @@ -0,0 +1,353 @@ +package handlers + +import ( + "os" + "strings" + "testing" + + "github.com/ptdewey/pendulum-server/internal/config" + "github.com/tliron/glsp" +) + +// TestGenerateMetricsReport_Integration tests the complete metrics generation flow +func TestGenerateMetricsReport_Integration(t *testing.T) { + // Create a temporary CSV file with test data + tmpFile, err := os.CreateTemp("", "pendulum_test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write comprehensive test data + testData := `active,branch,directory,file,filetype,project,time +true,main,/home/user/project,main.go,go,myproject,2024-01-01 10:00:00 +false,main,/home/user/project,main.go,go,myproject,2024-01-01 10:01:00 +true,main,/home/user/project,test.go,go,myproject,2024-01-01 10:02:00 +true,feature,/home/user/project,api.go,go,myproject,2024-01-01 10:03:00 +false,feature,/home/user/docs,README.md,markdown,myproject,2024-01-01 10:04:00 +true,main,/home/user/project,utils.go,go,myproject,2024-01-01 10:05:00` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + tests := []struct { + name string + args []any + expectError bool + validateFunc func(string) error + }{ + { + name: "basic metrics report", + args: []any{ + map[string]any{ + "log_file": tmpFile.Name(), + "top_n": 3, + }, + }, + expectError: false, + validateFunc: func(result string) error { + // Check that we got a proper markdown report + if !strings.Contains(result, "# Pendulum Metrics Report") { + t.Error("Expected report header") + } + if !strings.Contains(result, "## Top") { + t.Error("Expected metrics sections") + } + if !strings.Contains(result, "Processing Summary") { + t.Error("Expected processing summary") + } + return nil + }, + }, + { + name: "metrics with exclusions", + args: []any{ + map[string]any{ + "log_file": tmpFile.Name(), + "top_n": 2, + "report_excludes": map[string]any{ + "file": []any{"test.*"}, + }, + "report_section_excludes": []any{"branch"}, + }, + }, + expectError: false, + validateFunc: func(result string) error { + // Should not contain branch metrics + if strings.Contains(result, "Branches") { + t.Error("Expected branch section to be excluded") + } + // Should not contain test.go (excluded by pattern) + if strings.Contains(result, "test.go") { + t.Error("Expected test.go to be excluded") + } + return nil + }, + }, + { + name: "invalid log file", + args: []any{ + map[string]any{ + "log_file": "/nonexistent/file.csv", + }, + }, + expectError: true, + }, + { + name: "invalid parameters", + args: []any{ + map[string]any{ + "log_file": tmpFile.Name(), + "top_n": -1, // Invalid + }, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock LSP context (we don't use it in our implementation) + ctx := (*glsp.Context)(nil) + + result, err := GenerateMetricsReport(ctx, tt.args) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if tt.validateFunc != nil { + if err := tt.validateFunc(result); err != nil { + t.Error(err) + } + } + }) + } +} + +func TestGenerateMetricsReport_WithConfig(t *testing.T) { + // Test using config default log file + tmpFile, err := os.CreateTemp("", "pendulum_config_test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write minimal test data + testData := `active,branch,directory,file,filetype,project,time +true,main,/home,test.go,go,project,2024-01-01 10:00:00` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + // Set up config with the test file + err = config.Setup( + config.WithActivityFile(tmpFile.Name()), + config.WithLogFile("/tmp/lsp.log"), + config.WithDebug(false), + ) + if err != nil { + t.Fatalf("Failed to setup config: %v", err) + } + + // Test with empty log_file (should use config default) + args := []any{ + map[string]any{ + "top_n": 5, + }, + } + + ctx := (*glsp.Context)(nil) + result, err := GenerateMetricsReport(ctx, args) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !strings.Contains(result, "# Pendulum Metrics Report") { + t.Error("Expected valid report when using config default") + } +} + +func TestGenerateMetricsReport_EmptyFile(t *testing.T) { + // Test with empty CSV file + tmpFile, err := os.CreateTemp("", "pendulum_empty*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write only header + testData := `active,branch,directory,file,filetype,project,time` + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + args := []any{ + map[string]any{ + "log_file": tmpFile.Name(), + }, + } + + ctx := (*glsp.Context)(nil) + result, err := GenerateMetricsReport(ctx, args) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if !strings.Contains(result, "No data available") { + t.Error("Expected 'No data available' message for empty file") + } +} + +func TestGenerateHourlyReport(t *testing.T) { + // Create a temporary CSV file with test data + tmpFile, err := os.CreateTemp("", "pendulum_hours_test*.csv") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Write test data with different hours + testData := `active,branch,directory,file,filetype,project,time +true,main,/home/user/project,main.go,go,myproject,2024-01-01 10:00:00 +false,main,/home/user/project,main.go,go,myproject,2024-01-01 10:01:00 +true,main,/home/user/project,test.go,go,myproject,2024-01-01 10:02:00 +true,feature,/home/user/project,api.go,go,myproject,2024-01-01 14:00:00 +false,feature,/home/user/docs,README.md,markdown,myproject,2024-01-01 14:01:00 +true,main,/home/user/project,utils.go,go,myproject,2024-01-01 14:02:00` + + if _, err := tmpFile.WriteString(testData); err != nil { + t.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + tests := []struct { + name string + args []any + expectError bool + validateFunc func(string) error + }{ + { + name: "basic hourly report", + args: []any{ + map[string]any{ + "log_file": tmpFile.Name(), + "top_n": 5, + }, + }, + expectError: false, + validateFunc: func(result string) error { + if !strings.Contains(result, "# Pendulum Hours Report") { + t.Error("Expected hours report header") + } + if !strings.Contains(result, "Times Most Active") { + t.Error("Expected 'Times Most Active' section") + } + if !strings.Contains(result, "Processing Summary") { + t.Error("Expected processing summary") + } + return nil + }, + }, + { + name: "invalid log file", + args: []any{ + map[string]any{ + "log_file": "/nonexistent/file.csv", + }, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := (*glsp.Context)(nil) + + result, err := GenerateHourlyReport(ctx, tt.args) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if tt.validateFunc != nil { + if err := tt.validateFunc(result); err != nil { + t.Error(err) + } + } + }) + } +} + +func BenchmarkGenerateMetricsReport(b *testing.B) { + // Create a larger test file for benchmarking + tmpFile, err := os.CreateTemp("", "pendulum_bench*.csv") + if err != nil { + b.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + // Generate more test data + var lines []string + lines = append(lines, "active,branch,directory,file,filetype,project,time") + + for i := 0; i < 1000; i++ { + active := "true" + if i%2 == 0 { + active = "false" + } + line := strings.Join([]string{ + active, + "main", + "/home/user/project", + "file.go", + "go", + "myproject", + "2024-01-01 10:00:00", + }, ",") + lines = append(lines, line) + } + + if _, err := tmpFile.WriteString(strings.Join(lines, "\n")); err != nil { + b.Fatalf("Failed to write test data: %v", err) + } + tmpFile.Close() + + args := []any{ + map[string]any{ + "log_file": tmpFile.Name(), + "top_n": 10, + }, + } + + ctx := (*glsp.Context)(nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := GenerateMetricsReport(ctx, args) + if err != nil { + b.Fatalf("Generate metrics failed: %v", err) + } + } +} diff --git a/internal/handlers/params/parser.go b/internal/handlers/params/parser.go new file mode 100644 index 0000000..557255b --- /dev/null +++ b/internal/handlers/params/parser.go @@ -0,0 +1,159 @@ +package params + +import ( + "fmt" + + "github.com/ptdewey/pendulum-server/internal/handlers/data" +) + +// ParseMetricsParams parses and validates LSP command arguments for metrics generation +func ParseMetricsParams(args []any) (*data.MetricsParams, error) { + if len(args) == 0 { + return nil, &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "no arguments provided for metrics command", + } + } + + // First argument should be a map containing the options + argMap, ok := args[0].(map[string]any) + if !ok { + return nil, &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "expected first argument to be an options map", + } + } + + params := &data.MetricsParams{ + TopN: 5, // defaults + TimeRange: "all", + TimeFormat: "12h", + TimeZone: "UTC", + TimeoutLen: 180.0, + ReportExcludes: make(map[string][]string), + ReportSectionExcludes: make([]string, 0), + } + + // Parse log_file (optional - can use config default) + if logFile, ok := argMap["log_file"].(string); ok { + params.LogFile = logFile + } + + // Parse top_n (optional) + if topN, ok := argMap["top_n"]; ok { + switch v := topN.(type) { + case int: + params.TopN = v + case int64: + params.TopN = int(v) + case float64: + params.TopN = int(v) + default: + return nil, &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "top_n must be a number", + } + } + } + + // Parse time_range (optional) + if timeRange, ok := argMap["time_range"].(string); ok { + params.TimeRange = timeRange + } + + // Parse timeout_len (optional) + if timeoutLen, ok := argMap["timeout_len"]; ok { + switch v := timeoutLen.(type) { + case int: + params.TimeoutLen = float64(v) + case int64: + params.TimeoutLen = float64(v) + case float64: + params.TimeoutLen = v + default: + return nil, &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "timeout_len must be a number", + } + } + } + + // Parse time_format (optional) + if timeFormat, ok := argMap["time_format"].(string); ok { + params.TimeFormat = timeFormat + } + + // Parse time_zone (optional) + if timeZone, ok := argMap["time_zone"].(string); ok { + params.TimeZone = timeZone + } + + // Parse report_excludes (optional) + if reportExcludes, ok := argMap["report_excludes"].(map[string]any); ok { + for key, value := range reportExcludes { + if excludeList, ok := value.([]any); ok { + stringList := make([]string, 0, len(excludeList)) + for _, item := range excludeList { + if str, ok := item.(string); ok { + stringList = append(stringList, str) + } + } + params.ReportExcludes[key] = stringList + } + } + } + + // Parse report_section_excludes (optional) + if sectionExcludes, ok := argMap["report_section_excludes"].([]any); ok { + for _, item := range sectionExcludes { + if str, ok := item.(string); ok { + params.ReportSectionExcludes = append(params.ReportSectionExcludes, str) + } + } + } + + // Validate parameters + if err := validateParams(params); err != nil { + return nil, err + } + + return params, nil +} + +// validateParams validates the parsed parameters +func validateParams(params *data.MetricsParams) error { + // Don't validate LogFile here - it can be empty and filled from config later + + if params.TopN < 1 { + return &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "top_n must be greater than 0", + } + } + + if params.TimeoutLen < 0 { + return &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: "timeout_len must be non-negative", + } + } + + validTimeRanges := map[string]bool{ + "all": true, + "today": true, + "day": true, + "week": true, + "month": true, + "year": true, + "hour": true, + } + + if !validTimeRanges[params.TimeRange] { + return &data.MetricsError{ + Type: data.ErrInvalidParameters, + Message: fmt.Sprintf("invalid time_range: %s", params.TimeRange), + } + } + + return nil +} diff --git a/internal/handlers/params/parser_test.go b/internal/handlers/params/parser_test.go new file mode 100644 index 0000000..6908da3 --- /dev/null +++ b/internal/handlers/params/parser_test.go @@ -0,0 +1,265 @@ +package params + +import ( + "testing" + + "github.com/ptdewey/pendulum-server/internal/handlers/data" +) + +func TestParseMetricsParams(t *testing.T) { + tests := []struct { + name string + args []any + expectError bool + validate func(*data.MetricsParams) error + }{ + { + name: "empty args", + args: []any{}, + expectError: true, + }, + { + name: "invalid arg type", + args: []any{"not a map"}, + expectError: true, + }, + { + name: "missing log_file is ok", + args: []any{ + map[string]any{ + "top_n": 5, + }, + }, + expectError: false, + validate: func(p *data.MetricsParams) error { + if p.LogFile != "" { + t.Errorf("Expected empty log_file, got '%s'", p.LogFile) + } + return nil + }, + }, + { + name: "minimal valid args", + args: []any{ + map[string]any{ + "log_file": "/path/to/log.csv", + }, + }, + expectError: false, + validate: func(p *data.MetricsParams) error { + if p.LogFile != "/path/to/log.csv" { + t.Errorf("Expected log_file '/path/to/log.csv', got '%s'", p.LogFile) + } + if p.TopN != 5 { // default + t.Errorf("Expected default top_n 5, got %d", p.TopN) + } + if p.TimeRange != "all" { // default + t.Errorf("Expected default time_range 'all', got '%s'", p.TimeRange) + } + return nil + }, + }, + { + name: "full valid args", + args: []any{ + map[string]any{ + "log_file": "/path/to/log.csv", + "top_n": 10, + "time_range": "day", + "timeout_len": 120.0, + "time_format": "24h", + "time_zone": "America/New_York", + "report_excludes": map[string]any{ + "file": []any{"test.*", ".*\\.tmp"}, + }, + "report_section_excludes": []any{"branch", "project"}, + }, + }, + expectError: false, + validate: func(p *data.MetricsParams) error { + if p.LogFile != "/path/to/log.csv" { + t.Errorf("Expected log_file '/path/to/log.csv', got '%s'", p.LogFile) + } + if p.TopN != 10 { + t.Errorf("Expected top_n 10, got %d", p.TopN) + } + if p.TimeRange != "day" { + t.Errorf("Expected time_range 'day', got '%s'", p.TimeRange) + } + if p.TimeoutLen != 120.0 { + t.Errorf("Expected timeout_len 120.0, got %f", p.TimeoutLen) + } + if p.TimeFormat != "24h" { + t.Errorf("Expected time_format '24h', got '%s'", p.TimeFormat) + } + if p.TimeZone != "America/New_York" { + t.Errorf("Expected time_zone 'America/New_York', got '%s'", p.TimeZone) + } + + // Check report excludes + fileExcludes := p.ReportExcludes["file"] + if len(fileExcludes) != 2 { + t.Errorf("Expected 2 file excludes, got %d", len(fileExcludes)) + } + + // Check section excludes + if len(p.ReportSectionExcludes) != 2 { + t.Errorf("Expected 2 section excludes, got %d", len(p.ReportSectionExcludes)) + } + + return nil + }, + }, + { + name: "invalid top_n type", + args: []any{ + map[string]any{ + "log_file": "/path/to/log.csv", + "top_n": "not a number", + }, + }, + expectError: true, + }, + { + name: "invalid timeout_len type", + args: []any{ + map[string]any{ + "log_file": "/path/to/log.csv", + "timeout_len": "not a number", + }, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params, err := ParseMetricsParams(tt.args) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if tt.validate != nil { + if err := tt.validate(params); err != nil { + t.Error(err) + } + } + }) + } +} + +func TestValidateParams(t *testing.T) { + tests := []struct { + name string + params *data.MetricsParams + expectError bool + }{ + { + name: "valid params", + params: &data.MetricsParams{ + LogFile: "/path/to/log.csv", + TopN: 5, + TimeRange: "all", + TimeoutLen: 180.0, + }, + expectError: false, + }, + { + name: "empty log file is ok", + params: &data.MetricsParams{ + LogFile: "", + TopN: 5, + TimeRange: "all", + TimeoutLen: 180.0, + }, + expectError: false, + }, + { + name: "invalid top_n", + params: &data.MetricsParams{ + LogFile: "/path/to/log.csv", + TopN: 0, + TimeRange: "all", + TimeoutLen: 180.0, + }, + expectError: true, + }, + { + name: "negative timeout", + params: &data.MetricsParams{ + LogFile: "/path/to/log.csv", + TopN: 5, + TimeRange: "all", + TimeoutLen: -1.0, + }, + expectError: true, + }, + { + name: "invalid time range", + params: &data.MetricsParams{ + LogFile: "/path/to/log.csv", + TopN: 5, + TimeRange: "invalid", + TimeoutLen: 180.0, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateParams(tt.params) + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestParseMetricsParams_NumberTypes(t *testing.T) { + // Test different number types that might come from JSON/LSP + tests := []struct { + name string + value any + expected int + }{ + {"int", 10, 10}, + {"int64", int64(15), 15}, + {"float64", 20.0, 20}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := []any{ + map[string]any{ + "log_file": "/test.csv", + "top_n": tt.value, + }, + } + + params, err := ParseMetricsParams(args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if params.TopN != tt.expected { + t.Errorf("Expected top_n %d, got %d", tt.expected, params.TopN) + } + }) + } +} diff --git a/internal/handlers/prettify/formatter.go b/internal/handlers/prettify/formatter.go new file mode 100644 index 0000000..45deb77 --- /dev/null +++ b/internal/handlers/prettify/formatter.go @@ -0,0 +1,409 @@ +package prettify + +import ( + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/ptdewey/pendulum-server/internal/handlers/data" +) + +// MetricsFormatter handles formatting of metrics data for display +type MetricsFormatter struct { + params *data.MetricsParams +} + +// NewMetricsFormatter creates a new metrics formatter +func NewMetricsFormatter(params *data.MetricsParams) *MetricsFormatter { + return &MetricsFormatter{params: params} +} + +// FormatMetrics converts a slice of PendulumMetric structs into formatted strings +func (f *MetricsFormatter) FormatMetrics(metrics []data.PendulumMetric) []string { + var lines []string + + // Add header with metadata + header := f.generateHeader() + if header != "" { + lines = append(lines, header) + } + + // Format each metric + for i, metric := range metrics { + if metric.Name != "" && len(metric.Value) != 0 { + formatted := f.formatMetric(metric, f.params.TopN) + lines = append(lines, formatted) + + // Add empty line between metrics (but not after the last one) + if i < len(metrics)-1 { + lines = append(lines, "") + } + } + } + + return lines +} + +// generateHeader creates a header with report metadata +func (f *MetricsFormatter) generateHeader() string { + var parts []string + + parts = append(parts, "# Pendulum Metrics Report") + parts = append(parts, fmt.Sprintf("**Generated:** %s", time.Now().Format("2006-01-02 15:04:05"))) + parts = append(parts, fmt.Sprintf("**Time Range:** %s", f.params.TimeRange)) + parts = append(parts, fmt.Sprintf("**Log File:** %s", truncateHome(f.params.LogFile))) + parts = append(parts, "") + + return strings.Join(parts, "\n") +} + +// formatMetric converts a single PendulumMetric struct into a formatted string +func (f *MetricsFormatter) formatMetric(metric data.PendulumMetric, n int) string { + keys := make([]string, 0, len(metric.Value)) + for k := range metric.Value { + keys = append(keys, k) + } + + // Sort by active time (descending) + sort.SliceStable(keys, func(a, b int) bool { + return metric.Value[keys[a]].ActiveTime > metric.Value[keys[b]].ActiveTime + }) + + n = min(n, len(keys)) + + // Find longest ID for alignment + maxIDLen := 15 + for i := 0; i < n; i++ { + idLen := len(f.truncatePath(metric.Value[keys[i]].ID)) + maxIDLen = max(maxIDLen, idLen) + } + + // Generate formatted output + name := f.titleCase(metric.Name) + var out strings.Builder + fmt.Fprintf(&out, "## Top %d %s\n", n, f.prettifyMetricName(name)) + + for i := 0; i < n; i++ { + entry := metric.Value[keys[i]] + if math.IsNaN(entry.ActivePct) { + continue + } + out.WriteString(f.formatEntry(entry, i+1, maxIDLen, n) + "\n") + } + + return out.String() +} + +// formatEntry converts a single PendulumEntry into a formatted string +func (f *MetricsFormatter) formatEntry(e *data.PendulumEntry, rank int, maxIDLen int, totalRanks int) string { + rankWidth := len(fmt.Sprintf("%d", totalRanks)) + format := fmt.Sprintf("%%%dd. %%-%ds: Total %%6s, Active %%6s (%%-5.2f%%%%)", + rankWidth, maxIDLen+1) + + return fmt.Sprintf(format, + rank, + f.truncatePath(e.ID), + f.formatDuration(e.TotalTime), + f.formatDuration(e.ActiveTime), + e.ActivePct*100) +} + +// prettifyMetricName converts metric names into a more readable form +func (f *MetricsFormatter) prettifyMetricName(name string) string { + switch name { + case "Cwd", "Directory": + return "Directories" + case "Branch": + return "Branches" + case "File": + return "Files" + case "Filetype": + return "File Types" + case "Project": + return "Projects" + default: + return fmt.Sprintf("%ss", name) + } +} + +// truncatePath truncates long file paths for better display +func (f *MetricsFormatter) truncatePath(path string) string { + maxLen := 50 + + path = truncateHome(path) + + if len(path) <= maxLen { + return path + } + + // For file paths, show the end part + if strings.Contains(path, string(filepath.Separator)) { + parts := strings.FieldsFunc(path, func(c rune) bool { + return c == filepath.Separator + }) + + if len(parts) > 1 { + // Try to show last few parts + result := parts[len(parts)-1] + for i := len(parts) - 2; i >= 0 && len(result) < maxLen-3; i-- { + candidate := parts[i] + string(filepath.Separator) + result + if len(candidate) <= maxLen-3 { + result = candidate + } else { + break + } + } + if len(result) < len(path) { + return "..." + result + } + } + } + + // Fallback: truncate from the start + return "..." + path[len(path)-maxLen+3:] +} + +// formatDuration formats a time duration for display +func (f *MetricsFormatter) formatDuration(d time.Duration) string { + if d == 0 { + return "0s" + } + + if d >= 24*time.Hour { + days := float64(d) / float64(24*time.Hour) + return fmt.Sprintf("%.2fd", days) + } else if d >= time.Hour { + hours := float64(d) / float64(time.Hour) + return fmt.Sprintf("%.2fh", hours) + } else if d >= time.Minute { + minutes := float64(d) / float64(time.Minute) + return fmt.Sprintf("%.2fm", minutes) + } else { + seconds := float64(d) / float64(time.Second) + return fmt.Sprintf("%.2fs", seconds) + } +} + +// titleCase converts a string to title case +func (f *MetricsFormatter) titleCase(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} + +func truncateHome(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path + } + + if strings.HasPrefix(path, home) { + rpath, err := filepath.Rel(home, path) + if err == nil { + path = "~" + string(filepath.Separator) + rpath + } + } + + return path +} + +// hourDuration is a helper struct for sorting hours by duration +type hourDuration struct { + hour int + duration time.Duration +} + +// FormatHours converts PendulumHours into a formatted string report +func (f *MetricsFormatter) FormatHours(hours *data.PendulumHours, topN int) []string { + var lines []string + + // Add header + lines = append(lines, f.generateHoursHeader()) + + // Format the hours report + formatted := f.formatHoursReport(hours, topN) + lines = append(lines, formatted) + + return lines +} + +// generateHoursHeader creates a header for the hours report +func (f *MetricsFormatter) generateHoursHeader() string { + var parts []string + + parts = append(parts, "# Pendulum Hours Report") + parts = append(parts, fmt.Sprintf("**Generated:** %s", time.Now().Format("2006-01-02 15:04:05"))) + parts = append(parts, fmt.Sprintf("**Log File:** %s", truncateHome(f.params.LogFile))) + parts = append(parts, "") + + return strings.Join(parts, "\n") +} + +// formatHoursReport formats the hourly activity data +func (f *MetricsFormatter) formatHoursReport(hours *data.PendulumHours, n int) string { + // Convert hour durations to local timezone + loc, err := time.LoadLocation(f.params.TimeZone) + if err != nil { + loc = time.UTC + } + + hourCountsActive := make(map[int]int) + hourDurationsActive := make(map[int]time.Duration) + hourDurationsTotal := make(map[int]time.Duration) + weekHourDurationsActive := make(map[int]time.Duration) + weekHourDurationsTotal := make(map[int]time.Duration) + + // Count active timestamps per hour + layout := "2006-01-02 15:04:05" + for _, ts := range hours.ActiveTimestamps { + t, err := time.Parse(layout, ts) + if err != nil { + continue + } + hourCountsActive[t.In(loc).Hour()]++ + } + + // Convert hours to local timezone + for k, v := range hours.ActiveTimeHours { + t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) + hourDurationsActive[t.In(loc).Hour()] += v + } + + for k, v := range hours.TotalTimeHours { + t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) + hourDurationsTotal[t.In(loc).Hour()] += v + } + + for k, v := range hours.ActiveTimeHoursRecent { + t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) + weekHourDurationsActive[t.In(loc).Hour()] += v + } + + for k, v := range hours.TotalTimeHoursRecent { + t := time.Date(2006, 1, 2, k, 0, 0, 0, time.UTC) + weekHourDurationsTotal[t.In(loc).Hour()] += v + } + + // Create and sort slice by active duration (with hour as secondary key for determinism) + var hourDurationSlice []hourDuration + for hour, duration := range hourDurationsActive { + hourDurationSlice = append(hourDurationSlice, hourDuration{hour: hour, duration: duration}) + } + + sort.SliceStable(hourDurationSlice, func(a, b int) bool { + if hourDurationSlice[a].duration != hourDurationSlice[b].duration { + return hourDurationSlice[a].duration > hourDurationSlice[b].duration + } + // Secondary sort by hour for deterministic ordering when durations are equal + return hourDurationSlice[a].hour < hourDurationSlice[b].hour + }) + + if n > len(hourDurationSlice) { + n = len(hourDurationSlice) + } + + if n == 0 { + return "No hourly activity data available." + } + + // Calculate column widths for alignment + var overallHoursWidth int + var recentHoursWidth int + + for _, d := range hourDurationsActive { + w := len(f.formatDuration(d)) + if overallHoursWidth < w { + overallHoursWidth = w + } + } + + for _, d := range weekHourDurationsActive { + w := len(f.formatDuration(d)) + if recentHoursWidth < w { + recentHoursWidth = w + } + } + + // Ensure minimum widths + overallHoursWidth = max(overallHoursWidth, 6) + recentHoursWidth = max(recentHoursWidth, 6) + + bulletWidth := len(fmt.Sprintf("%d", n)) + + // Calculate column widths for proper alignment + // Column format: "duration (pct%)" where pct is 5.2f = 6 chars + " (" + ")" = 9 extra + overallColWidth := overallHoursWidth + 9 + recentColWidth := recentHoursWidth + 9 + + // Ensure column widths are at least as wide as headers + overallHeader := "Overall (Active %)" + recentHeader := "This Week (Active %)" + overallColWidth = max(overallColWidth, len(overallHeader)) + recentColWidth = max(recentColWidth, len(recentHeader)) + + // Calculate max entry count width for right-alignment + maxEntryCount := 0 + for i := 0; i < min(n, len(hourDurationSlice)); i++ { + h24 := hourDurationSlice[i].hour + if c := hourCountsActive[h24]; c > maxEntryCount { + maxEntryCount = c + } + } + entryCountWidth := max(len(fmt.Sprintf("%d", maxEntryCount)), len("Entry Count")) + + var out strings.Builder + out.WriteString("## Times Most Active\n") + out.WriteString(fmt.Sprintf("%*s %-5s %*s %*s %*s\n", + bulletWidth, "", + "Time", + overallColWidth, overallHeader, + recentColWidth, recentHeader, + entryCountWidth, "Entry Count")) + + for i := 0; i < n; i++ { + h24 := hourDurationSlice[i].hour + c := hourCountsActive[h24] + dur := hourDurationsActive[h24] + weeklyDur := weekHourDurationsActive[h24] + + h := h24 + var period string + if f.params.TimeFormat == "12h" { + h = h24 % 12 + if h == 0 { + h = 12 + } + period = "AM" + if h24 >= 12 { + period = "PM" + } + } + + var overallPct, recentPct float64 + if total, exists := hourDurationsTotal[h24]; exists && total > 0 { + overallPct = float64(dur) / float64(total) * 100 + } + if total, exists := weekHourDurationsTotal[h24]; exists && total > 0 { + recentPct = float64(weeklyDur) / float64(total) * 100 + } + + // Format the duration + percentage as a single column value (right-aligned) + overallStr := fmt.Sprintf("%*s (%5.2f%%)", overallHoursWidth, f.formatDuration(dur), overallPct) + recentStr := fmt.Sprintf("%*s (%5.2f%%)", recentHoursWidth, f.formatDuration(weeklyDur), recentPct) + + out.WriteString(fmt.Sprintf("%*d. %2d%-2s %*s %*s %*d\n", + bulletWidth, i+1, + h, period, + overallColWidth, overallStr, + recentColWidth, recentStr, + entryCountWidth, c, + )) + } + + return out.String() +} diff --git a/internal/handlers/snapshot_test.go b/internal/handlers/snapshot_test.go new file mode 100644 index 0000000..fa7f256 --- /dev/null +++ b/internal/handlers/snapshot_test.go @@ -0,0 +1,486 @@ +package handlers + +import ( + "context" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/ptdewey/pendulum-server/internal/handlers/data" + "github.com/ptdewey/pendulum-server/internal/handlers/prettify" + "github.com/ptdewey/shutter" +) + +// Snapshot Testing Documentation: +// +// This file contains snapshot tests for the metrics report generation pipeline. +// Snapshot testing uses shutter (https://github.com/ptdewey/shutter) to capture +// and validate the full formatted output of reports. +// +// Key Features: +// - Deterministic Test Data: Uses testdata/synthetic_log.csv with fixed, unique +// active time values to ensure consistent, non-flaky test results +// - Scrubbers: Dynamic content (timestamps, file paths) is scrubbed to a placeholder +// before comparison to avoid false positives when environments differ +// - Full Pipeline Testing: Tests verify the complete flow: CSV read → Aggregation → +// Formatting → Report generation +// - Isolated Formatter Testing: Direct formatter tests validate output formatting +// without reliance on CSV data +// +// Test Data Details (testdata/synthetic_log.csv): +// - 106 lines (header + 105 data rows) +// - All entries dated 2024-06-15 with 1-minute intervals +// - Unique active time durations per metric to prevent non-deterministic sorting +// - Branches: main (37m), develop (22m), feature/auth (13m), bugfix/login (7m) +// - Projects: myproject (32m), webapp (22m), api-server (22m), notes (3m) +// - Files with unique durations: auth.go (16m), server.py (11m), app.js (9m), etc. +// +// Maintaining Tests: +// 1. If test output changes unexpectedly, review the diff carefully +// 2. If changes are intentional, update snapshots by running: +// go test ./internal/handlers -run Snapshot +// 3. Verify the updated snapshots in __snapshots__/ directory +// 4. Ensure all unique time values in test data remain unique to prevent +// non-deterministic sorting issues +// +// Scrubbers Applied: +// - Timestamp pattern: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} → +// - File path: .+synthetic_log\.csv → + +// getTestdataPath returns the absolute path to the testdata directory +func getTestdataPath() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(filename), "..", "..", "testdata") +} + +// TestMetricsReportSnapshot tests the full metrics report output using snapshot testing +func TestMetricsReportSnapshot(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params for the report + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 5, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + TimeoutLen: 180, // 3 minutes - entries in test data are 1 min apart + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate metrics + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumMetrics(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate metrics: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatMetrics(result.Metrics) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers for dynamic content + shutter.SnapString(t, "metrics_report_all", report, + // Scrub the generated timestamp (format: 2006-01-02 15:04:05) + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + // Scrub the log file path which varies by environment + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestMetricsReportSnapshotWithExclusions tests report with section exclusions +func TestMetricsReportSnapshotWithExclusions(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params with exclusions + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 3, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + TimeoutLen: 180, + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{"branch", "directory"}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate metrics + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumMetrics(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate metrics: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatMetrics(result.Metrics) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers + shutter.SnapString(t, "metrics_report_exclusions", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestMetricsReportSnapshotTopN tests report with different TopN values +func TestMetricsReportSnapshotTopN(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params with TopN = 2 + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 2, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + TimeoutLen: 180, + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate metrics + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumMetrics(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate metrics: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatMetrics(result.Metrics) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers + shutter.SnapString(t, "metrics_report_top2", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestFormatterSnapshotDirectly tests the formatter output directly without full pipeline +func TestFormatterSnapshotDirectly(t *testing.T) { + // Create synthetic metrics data directly + metrics := []data.PendulumMetric{ + { + Name: "branch", + Value: map[string]*data.PendulumEntry{ + "main": { + ID: "main", + TotalTime: 30 * time.Minute, + ActiveTime: 25 * time.Minute, + ActivePct: 0.833, + }, + "feature/auth": { + ID: "feature/auth", + TotalTime: 20 * time.Minute, + ActiveTime: 18 * time.Minute, + ActivePct: 0.90, + }, + }, + }, + { + Name: "project", + Value: map[string]*data.PendulumEntry{ + "myproject": { + ID: "myproject", + TotalTime: 45 * time.Minute, + ActiveTime: 40 * time.Minute, + ActivePct: 0.889, + }, + "webapp": { + ID: "webapp", + TotalTime: 15 * time.Minute, + ActiveTime: 12 * time.Minute, + ActivePct: 0.80, + }, + }, + }, + } + + params := &data.MetricsParams{ + LogFile: "/test/pendulum.csv", + TopN: 5, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + } + + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatMetrics(metrics) + + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + shutter.SnapString(t, "formatter_direct", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + ) +} + +// TestHoursReportSnapshot tests the full hours report output using snapshot testing +func TestHoursReportSnapshot(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params for the report + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 5, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + TimeoutLen: 180, // 3 minutes - entries in test data are 1 min apart + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate hours + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumHours(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate hours: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatHours(result.Hours, params.TopN) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers for dynamic content + shutter.SnapString(t, "hours_report_all", report, + // Scrub the generated timestamp (format: 2006-01-02 15:04:05) + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + // Scrub the log file path which varies by environment + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestHoursReportSnapshot12h tests hours report with 12-hour time format +func TestHoursReportSnapshot12h(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params with 12h format + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 5, + TimeRange: "all", + TimeFormat: "12h", + TimeZone: "UTC", + TimeoutLen: 180, + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate hours + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumHours(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate hours: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatHours(result.Hours, params.TopN) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers + shutter.SnapString(t, "hours_report_12h", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestHoursReportSnapshotTopN tests hours report with different TopN values +func TestHoursReportSnapshotTopN(t *testing.T) { + logFile := filepath.Join(getTestdataPath(), "synthetic_log.csv") + + // Create params with TopN = 3 + params := &data.MetricsParams{ + LogFile: logFile, + TopN: 3, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + TimeoutLen: 180, + ReportExcludes: map[string][]string{}, + ReportSectionExcludes: []string{}, + } + + // Read CSV data + csvReader := data.NewCSVReader(logFile) + csvData, err := csvReader.ReadAll() + if err != nil { + t.Fatalf("Failed to read CSV: %v", err) + } + + // Aggregate hours + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aggregator := data.NewMetricsAggregator(params) + result, err := aggregator.AggregatePendulumHours(ctx, csvData) + if err != nil { + t.Fatalf("Failed to aggregate hours: %v", err) + } + + // Format the report + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatHours(result.Hours, params.TopN) + + // Join lines into final report + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + // Snapshot with scrubbers + shutter.SnapString(t, "hours_report_top3", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + shutter.ScrubRegex(`\*\*Log File:\*\* .+synthetic_log\.csv`, "**Log File:** "), + ) +} + +// TestHoursFormatterSnapshotDirectly tests the hours formatter output directly without full pipeline +func TestHoursFormatterSnapshotDirectly(t *testing.T) { + // Create synthetic hours data directly + hours := &data.PendulumHours{ + ActiveTimestamps: []string{ + "2024-06-15 09:00:00", + "2024-06-15 09:01:00", + "2024-06-15 10:00:00", + "2024-06-15 10:01:00", + "2024-06-15 10:02:00", + "2024-06-15 14:00:00", + }, + Timestamps: []string{ + "2024-06-15 09:00:00", + "2024-06-15 09:01:00", + "2024-06-15 09:02:00", + "2024-06-15 10:00:00", + "2024-06-15 10:01:00", + "2024-06-15 10:02:00", + "2024-06-15 10:03:00", + "2024-06-15 14:00:00", + "2024-06-15 14:01:00", + }, + ActiveTimeHours: map[int]time.Duration{ + 9: 10 * time.Minute, + 10: 25 * time.Minute, + 14: 15 * time.Minute, + }, + ActiveTimeHoursRecent: map[int]time.Duration{ + 9: 5 * time.Minute, + 10: 12 * time.Minute, + 14: 8 * time.Minute, + }, + TotalTimeHours: map[int]time.Duration{ + 9: 15 * time.Minute, + 10: 30 * time.Minute, + 14: 20 * time.Minute, + }, + TotalTimeHoursRecent: map[int]time.Duration{ + 9: 8 * time.Minute, + 10: 15 * time.Minute, + 14: 10 * time.Minute, + }, + } + + params := &data.MetricsParams{ + LogFile: "/test/pendulum.csv", + TopN: 5, + TimeRange: "all", + TimeFormat: "24h", + TimeZone: "UTC", + } + + formatter := prettify.NewMetricsFormatter(params) + formattedLines := formatter.FormatHours(hours, params.TopN) + + report := "" + for _, line := range formattedLines { + report += line + "\n" + } + + shutter.SnapString(t, "hours_formatter_direct", report, + shutter.ScrubRegex(`\*\*Generated:\*\* \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`, "**Generated:** "), + ) +} diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go new file mode 100644 index 0000000..9bd32a3 --- /dev/null +++ b/internal/lsp/lsp.go @@ -0,0 +1,95 @@ +package lsp + +import ( + "fmt" + "log" + + "github.com/ptdewey/pendulum-server/internal/config" + "github.com/ptdewey/pendulum-server/internal/handlers" + "github.com/tliron/glsp" + protocol "github.com/tliron/glsp/protocol_3_16" +) + +const ( + cmdLogActivity string = "pendulum.logActivity" + cmdActivityPing string = "pendulum.activityPing" + cmdStartSession string = "pendulum.startSession" + cmdEndSession string = "pendulum.endSession" + cmdGenerateMetricsReport string = "pendulum.generateMetricsReport" + cmdGenerateHourlyReport string = "pendulum.generateHourlyReport" +) + +func Initialize(ctx *glsp.Context, params *protocol.InitializeParams) (any, error) { + capabilities := protocol.ServerCapabilities{ + ExecuteCommandProvider: &protocol.ExecuteCommandOptions{ + Commands: []string{ + cmdLogActivity, cmdActivityPing, cmdStartSession, cmdEndSession, + cmdGenerateMetricsReport, cmdGenerateHourlyReport, + }, + }, + } + + return protocol.InitializeResult{ + Capabilities: capabilities, + ServerInfo: &protocol.InitializeResultServerInfo{ + Name: "pendulum-server", + Version: &[]string{"2.0.0"}[0], + }, + }, nil +} + +func Initialized(ctx *glsp.Context, params *protocol.InitializedParams) error { + log.Println("Server initialized successfully") + + ctx.Notify(protocol.ServerWindowLogMessage, &protocol.ShowMessageParams{ + Type: protocol.MessageTypeInfo, + Message: "pendulum-server is ready", + }) + + // Auto-start activity manager with CLI config + cfg := config.Config() + if cfg != nil { + handlers.InitializeActivityManager(ctx, cfg.TimeoutLen, cfg.TimerLen) + log.Printf("Activity manager auto-started with timeout: %v, interval: %v", cfg.TimeoutLen, cfg.TimerLen) + } + + return nil +} + +func Shutdown(ctx *glsp.Context) error { + log.Println("pendulum-server shutting down") + + // Clean up activity manager + if am := handlers.GetActivityManager(); am != nil { + am.Stop() + } + + return nil +} + +func WorkspaceExecuteCommand(ctx *glsp.Context, params *protocol.ExecuteCommandParams) (any, error) { + switch params.Command { + case cmdLogActivity: + return handlers.LogActivity(ctx, params.Arguments) + case cmdActivityPing: + return handlers.ActivityPing(ctx, params.Arguments) + case cmdStartSession: + return handlers.StartSession(ctx, params.Arguments) + case cmdEndSession: + return handlers.EndSession(ctx, params.Arguments) + case cmdGenerateMetricsReport: + return handlers.GenerateMetricsReport(ctx, params.Arguments) + case cmdGenerateHourlyReport: + return handlers.GenerateHourlyReport(ctx, params.Arguments) + default: + return nil, fmt.Errorf("unknown command: %s", params.Command) + } +} + +// These may of potential use (removing the need for some autocommands) +// TextDocumentDidOpen TextDocumentDidOpenFunc +// TextDocumentDidChange TextDocumentDidChangeFunc +// TextDocumentWillSave TextDocumentWillSaveFunc +// TextDocumentWillSaveWaitUntil TextDocumentWillSaveWaitUntilFunc +// TextDocumentDidSave TextDocumentDidSaveFunc +// TextDocumentDidClose TextDocumentDidCloseFunc diff --git a/justfile b/justfile new file mode 100644 index 0000000..0c603d4 --- /dev/null +++ b/justfile @@ -0,0 +1,18 @@ +[private] +default: + go build + +fmt: + @echo "Formatting lua/yankbank..." + @stylua lua/ --config-path=stylua.toml + @echo "Formatting Go files in ./remote..." + @find ./remote -name '*.go' -exec gofmt -w {} + + +lint: + @echo "Linting lua/yankbank..." + @luacheck lua/ --globals vim + +pr-ready: fmt lint + +test: + @go test ./... -cover -coverprofile=cover.out diff --git a/lua/pendulum/csv.lua b/lua/pendulum/csv.lua deleted file mode 100644 index 0e0602f..0000000 --- a/lua/pendulum/csv.lua +++ /dev/null @@ -1,112 +0,0 @@ -local M = {} - ----@param field any ----@return string -local function escape_csv_field(field) - if type(field) == "string" and (field:find('[,"]') or field:find("\n")) then - field = '"' .. field:gsub('"', '""') .. '"' - end - return tostring(field) -end - ----convert lua table to csv style table ----@param t table ----@return string, table -local function table_to_csv(t) - if #t == 0 then - return "", {} - end - - local csv_data = {} - local headers = {} - - for key, _ in pairs(t[1]) do - table.insert(headers, key) - end - table.sort(headers) - - for _, row in ipairs(t) do - local temp = {} - for _, field_key in ipairs(headers) do - table.insert(temp, escape_csv_field(row[field_key])) - end - - -- Incomplete rows can sometimes occur when multiple Neovim sessions are open, - -- so validating the number of entries matches the number of headers prevents - -- incomplete insertions. - if #temp == #headers then - table.insert(csv_data, table.concat(temp, ",") .. "\n") - end - end - - return table.concat(csv_data), headers -end - ----write lua table to csv file ----@param filepath string ----@param data_table table ----@param include_header boolean -function M.write_table_to_csv(filepath, data_table, include_header) - filepath = filepath:gsub("\\", "\\\\") - local d = filepath:match("^(.*[\\/])") - if vim.fn.isdirectory(d) == 0 then - vim.fn.mkdir(d, "p") - end - local f = io.open(filepath, "a+") - if not f then - error("Error opening file: " .. filepath) - end - - local csv_content, headers = table_to_csv(data_table) - if f:seek("end") == 0 and include_header then - f:write(table.concat(headers, ",") .. "\n") - end - - if csv_content ~= "" then - f:write(csv_content) - else - print("No data to write.") - end - - f:close() -end - ----function to split a string by a given delimiter ----@param input string ----@param delimiter string ----@return table -local function split(input, delimiter) - local result = {} - for match in (input .. delimiter):gmatch("(.-)" .. delimiter) do - table.insert(result, match) - end - return result -end - ----read a csv file into a lua table ----@param filepath string ----@return table? -function M.read_csv(filepath) - local csv_file = io.open(filepath, "r") - if not csv_file then - print("Could not open file: " .. filepath) - return nil - end - - local headers = split(csv_file:read("*l"), ",") - local data = {} - - for line in csv_file:lines() do - local row = {} - local values = split(line, ",") - for i, header in ipairs(headers) do - row[header] = values[i] - end - table.insert(data, row) - end - - csv_file:close() - return data -end - -return M diff --git a/lua/pendulum/handlers.lua b/lua/pendulum/handlers.lua index 90abc92..b5f1da0 100644 --- a/lua/pendulum/handlers.lua +++ b/lua/pendulum/handlers.lua @@ -1,127 +1,332 @@ local M = {} -local csv = require("pendulum.csv") +local lsp_client = nil +local lsp_ready = false +local message_queue = {} +local stored_opts = nil ----initialize last active time -local last_active_time = os.time() +-- Get the plugin installation path +local function get_plugin_path() + -- Extract path from this file's location: .../pendulum-nvim/lua/pendulum/handlers.lua + return debug.getinfo(1).source:sub(2):match("(.*/)lua/pendulum/") +end + +-- Get the default binary path based on OS +local function get_default_bin_path() + local path = get_plugin_path() + if not path then + return nil + end -local flag = true + local uname = vim.loop.os_uname().sysname + local path_separator = (uname == "Windows_NT") and "\\" or "/" + local bin_name = (uname == "Windows_NT") and "pendulum-lsp.exe" or "pendulum-lsp" ----update last active time -local function update_activity() - last_active_time = os.time() + return path .. "bin" .. path_separator .. bin_name end ----get the name of the git project ----@return string -local function git_project() - -- TODO: possibly change cwd to file path (to capture its git project while in a different working directory) - local project_name = vim.system( - { "git", "config", "--local", "remote.origin.url" }, - { text = true, cwd = vim.loop.cwd() } - ) - :wait().stdout +local function flush_queue() + if not lsp_ready or not lsp_client or lsp_client:is_stopped() then + return + end - if project_name then - project_name = project_name:gsub("%s+$", ""):match(".*/([^.]+)%.git$") + for _, msg in ipairs(message_queue) do + lsp_client:request("workspace/executeCommand", { + command = msg.command, + arguments = msg.args and { msg.args } or {}, + }, function(err, _) + if err then + vim.notify( + "Failed to execute " + .. msg.command + .. ": " + .. tostring(err.message or err), + vim.log.levels.ERROR + ) + end + end, 0) end - return project_name or "unknown_project" + message_queue = {} end ----get name of current git branch ----@return string -local function git_branch() - -- TODO: possibly change cwd to file path (to capture its git project while in a different working directory) - local branch_name = vim.system( - { "git", "branch", "--show-current" }, - { text = true, cwd = vim.loop.cwd() } - ) - :wait().stdout +local function send_to_lsp(command, args) + local msg = { command = command, args = args } + + if not lsp_ready or not lsp_client or lsp_client:is_stopped() then + -- Queue the message to send when connection is ready + table.insert(message_queue, msg) + return + end + + lsp_client:request("workspace/executeCommand", { + command = command, + arguments = args and { args } or {}, + }, function(err, _) + if err then + vim.notify( + "Failed to execute " + .. command + .. ": " + .. tostring(err.message or err), + vim.log.levels.ERROR + ) + end + end, 0) +end + +-- Synchronous version of send_to_lsp for critical operations like VimLeave +local function send_to_lsp_sync(command, args, timeout_ms) + timeout_ms = timeout_ms or 1000 - if not branch_name or branch_name == "" or branch_name:match("^fatal:") then - return "unknown_branch" + if not lsp_ready or not lsp_client or lsp_client:is_stopped() then + return false end - return branch_name:gsub("%s+$", "") or "unknown_branch" + local done = false + local success = false + + lsp_client:request("workspace/executeCommand", { + command = command, + arguments = args and { args } or {}, + }, function(err, _) + if err then + vim.notify( + "Failed to execute " + .. command + .. ": " + .. tostring(err.message or err), + vim.log.levels.ERROR + ) + success = false + else + success = true + end + done = true + end, 0) + + -- Wait for the request to complete or timeout + local wait_result = vim.wait(timeout_ms, function() + return done + end, 10) + + return wait_result and success end ----get table of tracked metrics ----@param is_active boolean ----@param active_time integer? ----@return table -local function log_activity(is_active, opts, active_time) - local _ = active_time - local ft = vim.bo.filetype - if ft == "" then - ft = "unknown_filetype" +local function ping_activity() + -- Skip special buffer types (terminal, quickfix, help, etc.) + local buftype = vim.bo.buftype + if buftype ~= "" then + return end + + send_to_lsp("pendulum.activityPing") +end + +local function log_full_activity(filepath) + -- Skip special buffer types (terminal, quickfix, help, etc.) + local buftype = vim.bo.buftype + if buftype ~= "" then + return + end + + -- Use provided filepath or get current buffer's path + local file = filepath or vim.fn.expand("%:p") + if file == "" then + return + end + + -- Skip terminal buffers and other non-file buffers + if file:match("^term://") or file:match("^fugitive://") or file:match("^oil://") then + return + end + + local ft = vim.bo.filetype ~= "" and vim.bo.filetype or "unknown_filetype" + local data = { - -- time = vim.fn.strftime("%Y-%m-%d %H:%M:%S"), -- Use local time zone instead time = os.date("!%Y-%m-%d %H:%M:%S"), - active = tostring(is_active), - -- file = vim.fn.expand("%:t+"), -- only file name - file = vim.fn.expand("%:p"), -- file name with path - -- TODO: file path - filename -> handoff to git to get file names - -- - change cwd to file path without filename + active = true, + file = file, filetype = ft, cwd = vim.loop.cwd(), - project = git_project(), - branch = git_branch(), } - if data.file ~= "" then - csv.write_table_to_csv(opts.log_file, { data }, true) - end - return data + send_to_lsp("pendulum.logActivity", data) end ----Check if the user is currently active ----@param opts table -local function check_active_status(opts) - local is_active = os.time() - last_active_time < opts.timeout_len +local function init_lsp_client(opts) + if lsp_client and not lsp_client:is_stopped() then + return lsp_client + end + + -- Reset state + lsp_client = nil + lsp_ready = false - -- for first non-active entry, log last active time - if not is_active and flag then - flag = false - log_activity(true, opts, last_active_time) - elseif is_active and not flag then - flag = true + if not opts.lsp_binary then + -- Binary path not set, remote.lua will handle building + return nil end - log_activity(is_active, opts) + local stat = vim.loop.fs_stat(opts.lsp_binary) + + if not stat then + -- Binary doesn't exist, remote.lua will handle building + return nil + end + + if vim.fn.executable(opts.lsp_binary) ~= 1 then + vim.notify( + "Pendulum LSP binary is not executable: " .. opts.lsp_binary, + vim.log.levels.ERROR + ) + return nil + end + + local client_id = vim.lsp.start({ + name = "pendulum-lsp", + cmd = { + opts.lsp_binary, + "--csv-path", + opts.log_file, + "--activity-timeout", + tostring(opts.timeout_len), + "--check-interval", + tostring(opts.timer_len), + }, + root_dir = vim.loop.cwd(), + filetypes = {}, + on_init = function(client, initialize_result) + -- LSP handshake complete - server is ready to receive commands + -- Set lsp_client immediately since on_init fires before vim.lsp.start() returns + lsp_client = client + lsp_ready = true + -- Flush any queued messages + vim.schedule(function() + flush_queue() + end) + end, + on_attach = function(client, bufnr) + vim.lsp.log.debug("Pendulum LSP attached") + end, + on_exit = function(code, signal, _) + lsp_client = nil + lsp_ready = false + if code ~= 0 then + vim.notify( + string.format( + "Pendulum LSP server exited with code: %s, signal: %s", + tostring(code), + tostring(signal) + ), + vim.log.levels.WARN + ) + end + end, + }) + + if client_id then + lsp_client = vim.lsp.get_client_by_id(client_id) + vim.lsp.log.debug( + "Pendulum LSP client started with ID: " .. client_id, + vim.log.levels.INFO + ) + end + + return lsp_client +end + +function M.get_lsp_client() + return lsp_client +end + +-- Reinitialize LSP client (called after binary is built) +function M.reinit_lsp() + if stored_opts then + return init_lsp_client(stored_opts) + end + return nil end ----Setup periodic activity checks ----@param opts table function M.setup(opts) - update_activity() + opts = opts or {} + opts.timeout_len = opts.timeout_len or 5 + opts.timer_len = opts.timer_len or 1 + + -- Determine binary path - use provided path or default to plugin bin directory + if not opts.lsp_binary then + opts.lsp_binary = get_default_bin_path() + end + + -- Store opts for potential reinit + stored_opts = opts + + if not opts.log_file then + vim.notify( + "Pendulum: log_file is required in setup options", + vim.log.levels.ERROR + ) + return + end - -- create autocommand group vim.api.nvim_create_augroup("Pendulum", { clear = true }) - -- define autocmd to update last active time vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { + group = "Pendulum", + callback = ping_activity, + }) + + vim.api.nvim_create_autocmd( + { "BufReadPost", "BufEnter", "BufLeave" }, + { + group = "Pendulum", + callback = function() + log_full_activity() + end, + } + ) + + -- VimEnter needs special handling - the buffer may not be ready yet + vim.api.nvim_create_autocmd({ "VimEnter" }, { group = "Pendulum", callback = function() - update_activity() + -- Defer to ensure buffer is loaded when opening nvim with a file argument + vim.defer_fn(function() + local file = vim.fn.expand("%:p") + if file ~= "" then + log_full_activity(file) + end + end, 10) end, }) - -- define autocmd for logging events - vim.api.nvim_create_autocmd({ "BufEnter", "VimLeave" }, { + vim.api.nvim_create_autocmd({ "VimLeave" }, { group = "Pendulum", callback = function() - log_activity(true, opts) + if lsp_client and not lsp_client:is_stopped() then + -- Use synchronous logging for VimLeave to ensure logs are written before exit + -- Skip special buffer types + local buftype = vim.bo.buftype + local file = vim.fn.expand("%:p") + if file ~= "" and buftype == "" and not file:match("^term://") and not file:match("^fugitive://") and not file:match("^oil://") then + local ft = vim.bo.filetype ~= "" and vim.bo.filetype + or "unknown_filetype" + local data = { + time = os.date("!%Y-%m-%d %H:%M:%S"), + active = true, + file = file, + filetype = ft, + cwd = vim.loop.cwd(), + } + send_to_lsp_sync("pendulum.logActivity", data, 1000) + end + send_to_lsp_sync("pendulum.endSession", nil, 500) + end end, }) - -- logging timer - vim.fn.timer_start(opts.timer_len * 1000, function() - vim.schedule(function() - check_active_status(opts) - end) - end, { ["repeat"] = -1 }) + -- Initialize LSP client (will silently fail if binary doesn't exist) + init_lsp_client(opts) end return M diff --git a/lua/pendulum/init.lua b/lua/pendulum/init.lua index fffce35..76de269 100644 --- a/lua/pendulum/init.lua +++ b/lua/pendulum/init.lua @@ -1,37 +1,34 @@ local M = {} -local handlers = require("pendulum.handlers") -local remote = require("pendulum.remote") - --- default plugin options -local default_opts = { - log_file = vim.env.HOME .. "/pendulum-log.csv", - timeout_len = 180, - timer_len = 120, - gen_reports = true, - top_n = 5, - hours_n = 10, - time_format = "12h", - time_zone = "UTC", -- Format "America/New_York" - report_excludes = { - branch = {}, - directory = {}, - file = {}, - filetype = {}, - project = {}, - }, - report_section_excludes = {}, -} - ---set up plugin autocommands with user options ---@param opts table? function M.setup(opts) + local handlers = require("pendulum.handlers") + local remote = require("pendulum.remote") + + -- default plugin options + local default_opts = { + log_file = vim.env.HOME .. "/pendulum-log.csv", + timeout_len = 180, + timer_len = 120, + top_n = 5, + hours_n = 10, + time_format = "12h", + time_zone = "UTC", -- Format "America/New_York" + report_excludes = { + branch = {}, + directory = {}, + file = {}, + filetype = {}, + project = {}, + }, + report_section_excludes = {}, + lsp_binary = nil, + } + opts = vim.tbl_deep_extend("force", default_opts, opts or {}) handlers.setup(opts) - - if opts.gen_reports == true then - remote.setup(opts) - end + remote.setup(opts) end return M diff --git a/lua/pendulum/remote.lua b/lua/pendulum/remote.lua index caf02b1..92c2a04 100644 --- a/lua/pendulum/remote.lua +++ b/lua/pendulum/remote.lua @@ -1,145 +1,350 @@ local M = {} -local chan -local bin_path -local plugin_path - local options = {} +local plugin_path = nil +local bin_path = nil + +-- Get the plugin installation path +local function get_plugin_path() + if plugin_path then + return plugin_path + end + -- Extract path from this file's location: .../pendulum-nvim/lua/pendulum/remote.lua + plugin_path = debug.getinfo(1).source:sub(2):match("(.*/)lua/pendulum/") + return plugin_path +end + +-- Get the binary path based on OS +local function get_bin_path() + if bin_path then + return bin_path + end + + local path = get_plugin_path() + if not path then + return nil + end + + local uname = vim.loop.os_uname().sysname + local path_separator = (uname == "Windows_NT") and "\\" or "/" + local bin_name = (uname == "Windows_NT") and "pendulum-lsp.exe" + or "pendulum-lsp" ---- job runner for pendulum remote binary ----@return integer? -local function ensure_job() - if chan then - return chan + bin_path = path .. "bin" .. path_separator .. bin_name + return bin_path +end + +-- Check if the binary exists +local function binary_exists() + local path = get_bin_path() + if not path then + return false end - if not bin_path then - print("Error: Pendulum binary not found.") + local stat = vim.loop.fs_stat(path) + return stat ~= nil +end + +-- Build the LSP binary +local function build_binary(callback) + local path = get_plugin_path() + if not path then + vim.notify("Could not determine plugin path", vim.log.levels.ERROR) + if callback then + callback(false) + end return end - chan = vim.fn.jobstart({ bin_path }, { - rpc = true, + vim.notify("Building Pendulum LSP binary with Go...", vim.log.levels.INFO) + + local target_bin = get_bin_path() + local build_cmd = string.format( + "cd %s && go build -o %s .", + vim.fn.shellescape(path), + vim.fn.shellescape(target_bin) + ) + + vim.fn.jobstart(build_cmd, { on_exit = function(_, code, _) - if code ~= 0 then - print("Error: Pendulum job exited with code " .. code) - chan = nil + if code == 0 then + vim.schedule(function() + vim.notify( + "Pendulum LSP binary compiled successfully.", + vim.log.levels.INFO + ) + if callback then + callback(true) + end + end) + else + vim.schedule(function() + vim.notify( + "Failed to compile Pendulum LSP binary. Make sure Go is installed.", + vim.log.levels.ERROR + ) + if callback then + callback(false) + end + end) end end, on_stderr = function(_, data, _) for _, line in ipairs(data) do if line ~= "" then - print("stderr: " .. line) - end - end - end, - on_stdout = function(_, data, _) - for _, line in ipairs(data) do - if line ~= "" then - print("stdout: " .. line) + vim.schedule(function() + vim.notify("Build: " .. line, vim.log.levels.WARN) + end) end end end, }) +end - if not chan or chan == 0 then - error("Failed to start pendulum-nvim job") +-- Function to create a buffer with the content received from the LSP +local function create_buffer(content, filetype) + -- Create a new scratch buffer + local buf = vim.api.nvim_create_buf(false, true) + + -- Set buffer options + vim.api.nvim_set_option_value( + "filetype", + filetype or "markdown", + { buf = buf } + ) + + -- Process content - LSP always returns a string + local lines = {} + + if type(content) == "string" then + -- Split string on newlines and add each line + for line in content:gmatch("[^\r\n]+") do + table.insert(lines, line) + end end - return chan + -- Set buffer content line by line + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + + -- Set buffer keymap for close + vim.api.nvim_buf_set_keymap( + buf, + "n", + "q", + "close!", + { silent = true } + ) + + return buf end ---- create plugin user commands to build binary and show report -local function setup_pendulum_commands() +-- Function to create a popup window with a buffer +local function create_popup_window(buf) + -- Get screen dimensions + local screen_width = vim.api.nvim_get_option_value("columns", {}) + local screen_height = vim.api.nvim_get_option_value("lines", {}) + + -- Calculate popup dimensions + local popup_width = math.floor(screen_width * 0.85) + local popup_height = math.floor(screen_height * 0.85) + + -- Create window config + local win_config = { + relative = "editor", + row = math.floor((screen_height - popup_height) / 2) - 1, + col = math.floor((screen_width - popup_width) / 2), + width = popup_width, + height = popup_height, + style = "minimal", + border = "rounded", + zindex = 50, + } + + -- Open window with buffer + local win = vim.api.nvim_open_win(buf, true, win_config) + + return win +end + +-- Function to handle LSP report generation responses +local function handle_lsp_response(err, result, context) + if err then + vim.notify( + "Error generating report: " .. vim.inspect(err), + vim.log.levels.ERROR + ) + return + end + + if not result then + vim.notify("No data returned from LSP", vim.log.levels.WARN) + return + end + + -- Create buffer with the content + local buf = create_buffer(result, "markdown") + + -- Create popup window + create_popup_window(buf) +end + +-- Setup pendulum commands for report generation +local function setup_pendulum_commands(lsp_client) + -- Valid time range options for command completion + local time_range_options = { "all", "day", "week", "month", "year", "hour" } + vim.api.nvim_create_user_command("Pendulum", function(args) - chan = ensure_job() - if not chan or chan == 0 then - print("Error: Invalid channel") + if not lsp_client or lsp_client:is_stopped() then + vim.notify( + "Pendulum LSP client not available. Try :PendulumRebuild", + vim.log.levels.ERROR + ) return end - options.time_range = args.args or "all" + -- Parse time range from command argument (e.g., :Pendulum week) + local time_range = "all" + if args.args and args.args ~= "" then + time_range = args.args + end + options.time_range = time_range options.view = "metrics" - local success, result = - pcall(vim.fn.rpcrequest, chan, "pendulum", options) - if not success then - print("RPC request failed: " .. result) - end - end, { nargs = "?" }) + -- Send request to LSP for metrics report + lsp_client:request("workspace/executeCommand", { + command = "pendulum.generateMetricsReport", + arguments = { options }, + }, handle_lsp_response) + end, { + nargs = "?", + force = true, + complete = function(arg_lead, cmd_line, cursor_pos) + -- Filter options based on what user has typed + local matches = {} + for _, opt in ipairs(time_range_options) do + if opt:find("^" .. arg_lead) then + table.insert(matches, opt) + end + end + return matches + end, + }) - vim.api.nvim_create_user_command("PendulumHours", function(args) - chan = ensure_job() - if not chan or chan == 0 then - print("Error: Invalid channel") + vim.api.nvim_create_user_command("PendulumHours", function() + if not lsp_client or lsp_client:is_stopped() then + vim.notify( + "Pendulum LSP client not available. Try :PendulumRebuild", + vim.log.levels.ERROR + ) return end options.view = "hours" - local success, result = - pcall(vim.fn.rpcrequest, chan, "pendulum", options) - if not success then - print("RPC request failed: " .. result) - end - end, { nargs = 0 }) + -- Send request to LSP for hours report + lsp_client:request("workspace/executeCommand", { + command = "pendulum.generateHourlyReport", + arguments = { options }, + }, handle_lsp_response) + end, { nargs = 0, force = true }) +end +-- Setup the PendulumRebuild command (always available) +local function setup_rebuild_command() vim.api.nvim_create_user_command("PendulumRebuild", function() - print("Rebuilding Pendulum binary with Go...") - local result = - os.execute("cd " .. plugin_path .. "remote" .. " && go build") - if result == 0 then - print("Go binary compiled successfully.") - if chan then - vim.fn.jobstop(chan) - chan = nil - end - else - print("Failed to compile Go binary.") + -- Stop existing LSP client if running + local handlers = require("pendulum.handlers") + local client = handlers.get_lsp_client() + if client and not client:is_stopped() then + vim.notify( + "Stopping Pendulum LSP before rebuild...", + vim.log.levels.INFO + ) + client:stop() end - end, { nargs = 0 }) + + build_binary(function(success) + if success then + -- Reinitialize the LSP client + vim.defer_fn(function() + local new_client = handlers.reinit_lsp() + if new_client then + setup_pendulum_commands(new_client) + vim.notify( + "Pendulum LSP reinitialized.", + vim.log.levels.INFO + ) + else + vim.notify( + "Pendulum LSP binary built. Restart Neovim to use it.", + vim.log.levels.INFO + ) + end + end, 500) + end + end) + end, { nargs = 0, force = true }) end ---- report generation setup (requires go) ----@param opts table +-- Report generation setup using LSP function M.setup(opts) options = opts - -- get plugin install path - plugin_path = debug.getinfo(1).source:sub(2):match("(.*/).*/.*/") + -- Determine binary path - use provided path or default to plugin bin directory + if not opts.lsp_binary then + opts.lsp_binary = get_bin_path() + end - -- check os to switch separators and binary extension if necessary - local uname = vim.loop.os_uname().sysname - local path_separator = (uname == "Windows_NT") and "\\" or "/" - bin_path = plugin_path - .. "remote" - .. path_separator - .. "pendulum-nvim" - .. (uname == "Windows_NT" and ".exe" or "") - - setup_pendulum_commands() - - -- check if binary exists - local uv = vim.loop - local handle = uv.fs_open(bin_path, "r", 438) - if handle then - uv.fs_close(handle) + -- Always set up the PendulumRebuild command + setup_rebuild_command() + + -- Check if binary exists, build if missing + if not binary_exists() then + vim.notify( + "Pendulum LSP binary not found, attempting to build...", + vim.log.levels.INFO + ) + build_binary(function(success) + if success then + -- Reinitialize the LSP client after build + vim.defer_fn(function() + local handlers = require("pendulum.handlers") + local new_client = handlers.reinit_lsp() + if new_client then + setup_pendulum_commands(new_client) + end + end, 500) + end + end) return end - -- compile binary if it doesn't exist - print( - "Pendulum binary not found at " - .. bin_path - .. ", attempting to compile with Go..." - ) + -- Binary exists, initialize commands + M.initialize_lsp_commands() +end - local result = - os.execute("cd " .. plugin_path .. "remote" .. " && go build") - if result == 0 then - print("Go binary compiled successfully.") +-- Initialize LSP commands (called after binary is available) +function M.initialize_lsp_commands() + -- Get LSP client from handlers module + local handlers = require("pendulum.handlers") + local lsp_client = handlers.get_lsp_client() + + -- Set up commands if client is available or wait for it + if lsp_client then + setup_pendulum_commands(lsp_client) else - print("Failed to compile Go binary." .. uv.cwd()) + -- If client not available yet, wait for it to be initialized + vim.defer_fn(function() + local client = handlers.get_lsp_client() + if client then + setup_pendulum_commands(client) + else + vim.notify( + "Pendulum LSP client not available. Try :PendulumRebuild", + vim.log.levels.WARN + ) + end + end, 1000) -- Wait 1 second for LSP to initialize end end diff --git a/main.go b/main.go new file mode 100644 index 0000000..bfc5b89 --- /dev/null +++ b/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "flag" + "log" + "time" + + "github.com/ptdewey/pendulum-server/internal/config" + "github.com/ptdewey/pendulum-server/internal/lsp" + "github.com/tliron/commonlog" + protocol "github.com/tliron/glsp/protocol_3_16" + "github.com/tliron/glsp/server" +) + +var name = "pendulum-server" + +var ( + csvPath = flag.String("csv-path", "pendulum-log.csv", "Path to CSV activity log file") + activityTimeout = flag.Int("activity-timeout", 180, "Activity timeout in seconds (how long to wait before marking as inactive)") + checkInterval = flag.Int("check-interval", 90, "Activity check interval in seconds (how often to check activity status)") + debug = flag.Bool("debug", false, "Enable debug mode with verbose logging") +) + +func main() { + flag.Parse() + + if err := config.Setup( + config.WithActivityFile(*csvPath), + config.WithTimeoutLen(time.Duration(*activityTimeout)*time.Second), + config.WithTimerLen(time.Duration(*checkInterval)*time.Second), + config.WithDebug(*debug), + ); err != nil { + log.Println(err) + return + } + + commonlog.Configure(1, &config.Config().LspLogFile) + + h := protocol.Handler{ + Initialize: lsp.Initialize, + Initialized: lsp.Initialized, + Shutdown: lsp.Shutdown, + WorkspaceExecuteCommand: lsp.WorkspaceExecuteCommand, + } + + s := server.NewServer(&h, name, config.Config().Debug) + + if err := s.RunStdio(); err != nil { + log.Println(err) + } +} diff --git a/remote/internal/prettify/metrics.go b/remote/internal/prettify/metrics.go index 2d528c4..03a6145 100644 --- a/remote/internal/prettify/metrics.go +++ b/remote/internal/prettify/metrics.go @@ -27,10 +27,15 @@ func PrettifyMetrics(metrics []data.PendulumMetric) []string { // - Do this in a utility function to use with the active time display as well // iterate over each metric - for _, metric := range metrics { + for i, metric := range metrics { // TODO: redefine order? (might require hardcoding) if metric.Name != "" && len(metric.Value) != 0 { lines = append(lines, prettifyMetric(metric, args.PendulumArgs().NMetrics)) + + // Add empty line between metrics (but not after the last one) + if i < len(metrics)-1 { + lines = append(lines, "") + } } } diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..eac3e9b --- /dev/null +++ b/selene.toml @@ -0,0 +1,4 @@ +std = "vim" + +[lints] +mixed_table = "allow" diff --git a/testdata/synthetic_log.csv b/testdata/synthetic_log.csv new file mode 100644 index 0000000..78473cf --- /dev/null +++ b/testdata/synthetic_log.csv @@ -0,0 +1,109 @@ +active,branch,directory,file,filetype,project,time +true,main,/home/dev/myproject,main.go,go,myproject,2024-06-15 09:00:00 +true,main,/home/dev/myproject,main.go,go,myproject,2024-06-15 09:01:00 +true,main,/home/dev/myproject,main.go,go,myproject,2024-06-15 09:02:00 +false,main,/home/dev/myproject,main.go,go,myproject,2024-06-15 09:03:00 +true,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:10:00 +true,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:11:00 +true,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:12:00 +true,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:13:00 +true,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:14:00 +false,main,/home/dev/myproject,handlers.go,go,myproject,2024-06-15 09:15:00 +true,main,/home/dev/myproject,utils.go,go,myproject,2024-06-15 09:30:00 +true,main,/home/dev/myproject,utils.go,go,myproject,2024-06-15 09:31:00 +false,main,/home/dev/myproject,utils.go,go,myproject,2024-06-15 09:32:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:00:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:01:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:02:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:03:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:04:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:05:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:06:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:07:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:08:00 +true,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:09:00 +false,feature/auth,/home/dev/myproject,auth.go,go,myproject,2024-06-15 10:10:00 +true,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:30:00 +true,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:31:00 +true,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:32:00 +true,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:33:00 +true,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:34:00 +false,feature/auth,/home/dev/myproject,auth_test.go,go,myproject,2024-06-15 10:35:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:00:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:01:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:02:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:03:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:04:00 +true,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:05:00 +false,main,/home/dev/myproject,README.md,markdown,myproject,2024-06-15 11:06:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:00:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:01:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:02:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:03:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:04:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:05:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:06:00 +true,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:07:00 +false,main,/home/dev/webapp,index.html,html,webapp,2024-06-15 14:08:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:30:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:31:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:32:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:33:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:34:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:35:00 +true,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:36:00 +false,main,/home/dev/webapp,styles.css,css,webapp,2024-06-15 14:37:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:00:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:01:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:02:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:03:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:04:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:05:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:06:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:07:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:08:00 +true,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:09:00 +false,main,/home/dev/webapp,app.js,javascript,webapp,2024-06-15 15:10:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:00:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:01:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:02:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:03:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:04:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:05:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:06:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:07:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:08:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:09:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:10:00 +true,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:11:00 +false,develop,/home/dev/api-server,server.py,python,api-server,2024-06-15 16:12:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:30:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:31:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:32:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:33:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:34:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:35:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:36:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:37:00 +true,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:38:00 +false,develop,/home/dev/api-server,routes.py,python,api-server,2024-06-15 16:39:00 +true,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:00:00 +true,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:01:00 +true,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:02:00 +true,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:03:00 +true,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:04:00 +false,develop,/home/dev/api-server,models.py,python,api-server,2024-06-15 17:05:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:30:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:31:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:32:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:33:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:34:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:35:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:36:00 +true,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:37:00 +false,bugfix/login,/home/dev/myproject,auth.go,go,myproject,2024-06-15 17:38:00 +true,main,/home/dev/notes,todo.md,markdown,notes,2024-06-15 18:00:00 +true,main,/home/dev/notes,todo.md,markdown,notes,2024-06-15 18:01:00 +true,main,/home/dev/notes,todo.md,markdown,notes,2024-06-15 18:02:00 +true,main,/home/dev/notes,todo.md,markdown,notes,2024-06-15 18:03:00 +false,main,/home/dev/notes,todo.md,markdown,notes,2024-06-15 18:04:00